mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-07 18:14:10 +00:00
Compare commits
646 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
94978ea9e0 | ||
![]() |
bf6999e439 | ||
![]() |
020ee7378f | ||
![]() |
e4a0569961 | ||
![]() |
4ff525d5bd | ||
![]() |
37a31b01b2 | ||
![]() |
1604cb1b0b | ||
![]() |
45702ac18c | ||
![]() |
c81e9d60e4 | ||
![]() |
224865b894 | ||
![]() |
3b3bc8224b | ||
![]() |
c56dc2ea6f | ||
![]() |
62202bbb74 | ||
![]() |
7ba28c0207 | ||
![]() |
9392a29dad | ||
![]() |
72ab8f99ec | ||
![]() |
fcf32c7e50 | ||
![]() |
da451d6552 | ||
![]() |
662b1a4d4a | ||
![]() |
732adea997 | ||
![]() |
7e1dbf3515 | ||
![]() |
65b92ec246 | ||
![]() |
dc42ee4779 | ||
![]() |
c04441c1b2 | ||
![]() |
c3faef8e2a | ||
![]() |
d2175635af | ||
![]() |
1f7401cd14 | ||
![]() |
c94b3e34d2 | ||
![]() |
566e1d05ea | ||
![]() |
0488d0bd73 | ||
![]() |
ca31d9b426 | ||
![]() |
8721f9010f | ||
![]() |
88de48ebac | ||
![]() |
d5a6e2b2ac | ||
![]() |
2152a94156 | ||
![]() |
bc3824e9bf | ||
![]() |
60bc92cf78 | ||
![]() |
3b15467738 | ||
![]() |
4970fe0a1c | ||
![]() |
7dbe2425b8 | ||
![]() |
433d44a642 | ||
![]() |
7733d320d0 | ||
![]() |
20d367c2a8 | ||
![]() |
4687fbe075 | ||
![]() |
b0dc52781e | ||
![]() |
4f1f7d6b8f | ||
![]() |
41f8608f4e | ||
![]() |
ba3a8f2e76 | ||
![]() |
12e3a5496d | ||
![]() |
280644bab5 | ||
![]() |
bf28371356 | ||
![]() |
ce237181f2 | ||
![]() |
85ca5a052e | ||
![]() |
db8b3dbce9 | ||
![]() |
9c2d56f015 | ||
![]() |
d244a1e02f | ||
![]() |
9f134277a9 | ||
![]() |
ef9aca7bcb | ||
![]() |
32f39f23eb | ||
![]() |
c9b2beb821 | ||
![]() |
e9ad82e350 | ||
![]() |
347dd3cc0f | ||
![]() |
798346dbe8 | ||
![]() |
fd94c6de17 | ||
![]() |
3fc6fc32c5 | ||
![]() |
a1b6aa5537 | ||
![]() |
f9965bb3c3 | ||
![]() |
541997371c | ||
![]() |
522c3e5bee | ||
![]() |
1baf434695 | ||
![]() |
92db71f293 | ||
![]() |
b985f8384d | ||
![]() |
4c2d049e70 | ||
![]() |
605c4f121c | ||
![]() |
4baf5035cb | ||
![]() |
f8a57eb7d9 | ||
![]() |
93ac343493 | ||
![]() |
dc092186f0 | ||
![]() |
6b7c319351 | ||
![]() |
ef5885f769 | ||
![]() |
0ffd53424d | ||
![]() |
5f464d01b4 | ||
![]() |
0a054cc651 | ||
![]() |
348af48d45 | ||
![]() |
4d03c00dab | ||
![]() |
7a71074a55 | ||
![]() |
5527a3e7dd | ||
![]() |
f961800fa4 | ||
![]() |
adbf961433 | ||
![]() |
73e130cb2c | ||
![]() |
a44f178b64 | ||
![]() |
057fe32e3b | ||
![]() |
cad9ffa453 | ||
![]() |
a11193a240 | ||
![]() |
ea61a580b3 | ||
![]() |
0bf6db92dd | ||
![]() |
b0f38e7626 | ||
![]() |
0f237f28e7 | ||
![]() |
d63bd944ac | ||
![]() |
54e28d759d | ||
![]() |
a00c13ba67 | ||
![]() |
b4bc5437dd | ||
![]() |
13bc0397f6 | ||
![]() |
9eb30f6ff6 | ||
![]() |
17f20d8593 | ||
![]() |
cd23e086a8 | ||
![]() |
03087f20fe | ||
![]() |
f536eb4629 | ||
![]() |
f3e814aa8a | ||
![]() |
5fb0a6dffe | ||
![]() |
c7ba86d1d8 | ||
![]() |
38dcc694b7 | ||
![]() |
fdfffefefa | ||
![]() |
4e7704afd9 | ||
![]() |
b52fcf4936 | ||
![]() |
539be2f08e | ||
![]() |
29b2836c50 | ||
![]() |
3a757d003a | ||
![]() |
236802be1f | ||
![]() |
4a2c9e97c6 | ||
![]() |
0444d8465c | ||
![]() |
faef34e4ff | ||
![]() |
c174ec42f0 | ||
![]() |
d484728de9 | ||
![]() |
7da7f7e074 | ||
![]() |
53bdcd7d74 | ||
![]() |
1849964699 | ||
![]() |
5163c7a97f | ||
![]() |
b9daef9947 | ||
![]() |
f16e0488ab | ||
![]() |
adc16be4dc | ||
![]() |
3e4b4149de | ||
![]() |
c392bae7e4 | ||
![]() |
2e5373aa37 | ||
![]() |
5412cd414f | ||
![]() |
d957c5158f | ||
![]() |
4a622cb964 | ||
![]() |
69e721de46 | ||
![]() |
f3f130f452 | ||
![]() |
fd4a04e3f3 | ||
![]() |
85c040ab8e | ||
![]() |
2bb4cd4739 | ||
![]() |
4c3b134f10 | ||
![]() |
bb8536b553 | ||
![]() |
8998fd480c | ||
![]() |
d948fed0b5 | ||
![]() |
fcfe6314ac | ||
![]() |
dcfe2aa792 | ||
![]() |
85790ab9d8 | ||
![]() |
adda2fcd90 | ||
![]() |
5604e983db | ||
![]() |
386563a10a | ||
![]() |
0e3c5cf625 | ||
![]() |
a3eb2d2b9a | ||
![]() |
b6a8860a44 | ||
![]() |
b8a649ae86 | ||
![]() |
7774bfc612 | ||
![]() |
9f76613aed | ||
![]() |
f1ccbe4bed | ||
![]() |
668d78f729 | ||
![]() |
0009b9a3d6 | ||
![]() |
b2be07ea6a | ||
![]() |
74649eaad0 | ||
![]() |
f33086aa13 | ||
![]() |
9c1cd960fc | ||
![]() |
3a5226ffa0 | ||
![]() |
96a53f9921 | ||
![]() |
ff92ac9dad | ||
![]() |
933478bfff | ||
![]() |
7d996f91b0 | ||
![]() |
c818cbb644 | ||
![]() |
e638e5b684 | ||
![]() |
625e76ea40 | ||
![]() |
f8229c9fb6 | ||
![]() |
47da422a93 | ||
![]() |
3dd98bc0fc | ||
![]() |
fa6e4aa449 | ||
![]() |
182472f921 | ||
![]() |
d99afe531d | ||
![]() |
b6b238073f | ||
![]() |
a4c696d3bd | ||
![]() |
bce767120c | ||
![]() |
6a9f346b21 | ||
![]() |
d4646e1caa | ||
![]() |
77f0e00695 | ||
![]() |
26a6c89b3a | ||
![]() |
34297b82b3 | ||
![]() |
70727c4940 | ||
![]() |
56080e5436 | ||
![]() |
309b1bda75 | ||
![]() |
f3ebb694b4 | ||
![]() |
f35c14318a | ||
![]() |
b60f2e8233 | ||
![]() |
f1a55e31ce | ||
![]() |
2432611264 | ||
![]() |
729b608eff | ||
![]() |
eb3252da28 | ||
![]() |
a9e9338ee4 | ||
![]() |
aad063e3cd | ||
![]() |
be00265d1a | ||
![]() |
335ba4f453 | ||
![]() |
5a4f3a4910 | ||
![]() |
7ee4be0f13 | ||
![]() |
10c3fbe5cf | ||
![]() |
13826a41a1 | ||
![]() |
cb35026637 | ||
![]() |
24c080cf4a | ||
![]() |
e9fc629285 | ||
![]() |
150b67c1c9 | ||
![]() |
acdee0ac29 | ||
![]() |
193b236ef1 | ||
![]() |
1851e6a29d | ||
![]() |
74f086629c | ||
![]() |
33a59c8352 | ||
![]() |
08644fea74 | ||
![]() |
f878bf6ad3 | ||
![]() |
651c457266 | ||
![]() |
2dd3463ea8 | ||
![]() |
ad93af8cc8 | ||
![]() |
080cf7a29b | ||
![]() |
b8f4803ef4 | ||
![]() |
4a8f51ed6d | ||
![]() |
7923074ed5 | ||
![]() |
834b2ba77d | ||
![]() |
7897a13ca5 | ||
![]() |
7987011372 | ||
![]() |
d7a76077bd | ||
![]() |
62731cf489 | ||
![]() |
5d501bc465 | ||
![]() |
63a6841848 | ||
![]() |
403241bd98 | ||
![]() |
de3fe88df6 | ||
![]() |
6a370286e1 | ||
![]() |
491b7e7d11 | ||
![]() |
0b0db97117 | ||
![]() |
42a993fd08 | ||
![]() |
fd1544bf41 | ||
![]() |
ed36207328 | ||
![]() |
a0b8ccf805 | ||
![]() |
9d2278d29b | ||
![]() |
df42385d7e | ||
![]() |
02796d4daa | ||
![]() |
80c5f67335 | ||
![]() |
0b14e89404 | ||
![]() |
f595b1ad59 | ||
![]() |
80ca1eacc5 | ||
![]() |
5b3ac6c840 | ||
![]() |
0000b7447a | ||
![]() |
a22060ca7f | ||
![]() |
8ca321ecc3 | ||
![]() |
862cb3640b | ||
![]() |
51908c9673 | ||
![]() |
9aa4046093 | ||
![]() |
acb49adfea | ||
![]() |
f345ad5422 | ||
![]() |
5ad618bfc1 | ||
![]() |
26b00578a1 | ||
![]() |
c3111b04bb | ||
![]() |
a61ba74360 | ||
![]() |
4de93fd1d5 | ||
![]() |
46bb7b05f4 | ||
![]() |
1aa2cb1921 | ||
![]() |
c4bfa63fd6 | ||
![]() |
4c5d6167bd | ||
![]() |
9a002c2445 | ||
![]() |
f97d32c5bd | ||
![]() |
bac311677f | ||
![]() |
94cb5b3a05 | ||
![]() |
ed4f0ba014 | ||
![]() |
fd219b5fff | ||
![]() |
140c4f2909 | ||
![]() |
a1c787ba5f | ||
![]() |
54c808fe98 | ||
![]() |
eaeec9f19b | ||
![]() |
21d25ac130 | ||
![]() |
eda21642bd | ||
![]() |
aace54d5b2 | ||
![]() |
e460c00759 | ||
![]() |
678fd1cd08 | ||
![]() |
42c78f3c43 | ||
![]() |
548e0f6153 | ||
![]() |
31f63c737f | ||
![]() |
71b35602d7 | ||
![]() |
7c41a024ba | ||
![]() |
51097de43d | ||
![]() |
44e16d538d | ||
![]() |
f6517d01db | ||
![]() |
039b925cf6 | ||
![]() |
bba5460236 | ||
![]() |
e5d3705a1a | ||
![]() |
7b80b95a49 | ||
![]() |
75cb487ab3 | ||
![]() |
eba4b3e8c7 | ||
![]() |
712b895d8e | ||
![]() |
635fd9b2c3 | ||
![]() |
afcbdd9bc4 | ||
![]() |
80fa5418b7 | ||
![]() |
b0a09c027d | ||
![]() |
4edf59efeb | ||
![]() |
9f0dec1247 | ||
![]() |
2c47fd4a02 | ||
![]() |
9878f1e32d | ||
![]() |
5c396668ff | ||
![]() |
5f12f9f2c3 | ||
![]() |
4974775cd9 | ||
![]() |
0cb777cd0f | ||
![]() |
a4bb25a75f | ||
![]() |
b3f117bc59 | ||
![]() |
499ba89f07 | ||
![]() |
05d743f725 | ||
![]() |
a347d56623 | ||
![]() |
172976208e | ||
![]() |
b6db3f59a2 | ||
![]() |
4b31279fc8 | ||
![]() |
bfef83cefc | ||
![]() |
07d599fed2 | ||
![]() |
0412407558 | ||
![]() |
4c568b46d6 | ||
![]() |
d92fcf5827 | ||
![]() |
36f3abbfc7 | ||
![]() |
49a45b13e6 | ||
![]() |
dfa13cb2c5 | ||
![]() |
fd3b959771 | ||
![]() |
39a80edb74 | ||
![]() |
2a35d1c8a6 | ||
![]() |
81350322d7 | ||
![]() |
50c2528359 | ||
![]() |
77bac30654 | ||
![]() |
41fafc74cf | ||
![]() |
c6281160fa | ||
![]() |
3159b61ae7 | ||
![]() |
11278ddb26 | ||
![]() |
e299a6c279 | ||
![]() |
22ff5f3d91 | ||
![]() |
a3e8bd346f | ||
![]() |
592a084a28 | ||
![]() |
c27e59b0f9 | ||
![]() |
1c9bc1b133 | ||
![]() |
be4f4853cf | ||
![]() |
7d8895c2fb | ||
![]() |
5b8913be5b | ||
![]() |
d03a1ee490 | ||
![]() |
19ae38c108 | ||
![]() |
9b71f11213 | ||
![]() |
8fbaedf4d7 | ||
![]() |
87ab07b322 | ||
![]() |
f36a1e10e6 | ||
![]() |
5944671663 | ||
![]() |
27dfd0edca | ||
![]() |
9dfc043352 | ||
![]() |
e8bd1520b2 | ||
![]() |
a30b9976f5 | ||
![]() |
954e5b3d5e | ||
![]() |
7cd8aa266b | ||
![]() |
d0449d136c | ||
![]() |
ff9aeb70b4 | ||
![]() |
2eaecd22ba | ||
![]() |
4801d647c1 | ||
![]() |
b7e6fa3abe | ||
![]() |
d590024c47 | ||
![]() |
f3f71c38c3 | ||
![]() |
27125a169c | ||
![]() |
3f9205d405 | ||
![]() |
96861dc2b0 | ||
![]() |
cedaa184f1 | ||
![]() |
f491791081 | ||
![]() |
6bba1c474f | ||
![]() |
357f6799b0 | ||
![]() |
ce3ea270f5 | ||
![]() |
992717adc0 | ||
![]() |
993101710f | ||
![]() |
ac6fe61804 | ||
![]() |
37aa1a291a | ||
![]() |
c6294f2763 | ||
![]() |
6e9a77f55f | ||
![]() |
799b407d89 | ||
![]() |
3ddfa5f939 | ||
![]() |
5968661742 | ||
![]() |
34592e3da5 | ||
![]() |
5aea7eda96 | ||
![]() |
08024be1c0 | ||
![]() |
39daff3099 | ||
![]() |
d4c0fe8679 | ||
![]() |
c9ae45bef3 | ||
![]() |
503f21fd37 | ||
![]() |
6d106b24f4 | ||
![]() |
71f47b7a70 | ||
![]() |
844381e7c9 | ||
![]() |
267994b191 | ||
![]() |
cc2202c188 | ||
![]() |
4996a84ca0 | ||
![]() |
3cefc2951c | ||
![]() |
835b4afc06 | ||
![]() |
146bef1d88 | ||
![]() |
ef9656eb8b | ||
![]() |
84868a6475 | ||
![]() |
9e9c6f2761 | ||
![]() |
19e8bdacfe | ||
![]() |
c6640aa51d | ||
![]() |
1514a2f2e2 | ||
![]() |
9edb282067 | ||
![]() |
9ffe5e6187 | ||
![]() |
14662111a8 | ||
![]() |
a7ea5774d9 | ||
![]() |
c998458362 | ||
![]() |
07ddede40c | ||
![]() |
b8a6ac62e8 | ||
![]() |
86e9a3217c | ||
![]() |
f591e6e3fb | ||
![]() |
64dd1db327 | ||
![]() |
b68569f61c | ||
![]() |
3a52e3f4df | ||
![]() |
05c268e190 | ||
![]() |
98937de278 | ||
![]() |
ff35e3b93e | ||
![]() |
4eebc95109 | ||
![]() |
c708c2a3a0 | ||
![]() |
35f8190128 | ||
![]() |
78b268ddef | ||
![]() |
eb99060a25 | ||
![]() |
8e99f659f5 | ||
![]() |
5c9e9d65b5 | ||
![]() |
3e768b7297 | ||
![]() |
aa2999210d | ||
![]() |
be95a27597 | ||
![]() |
5edcdd4fb2 | ||
![]() |
b81586de0a | ||
![]() |
e0f3e3b954 | ||
![]() |
3037d264c3 | ||
![]() |
17f1346c08 | ||
![]() |
276aba9f85 | ||
![]() |
0ba63c42fd | ||
![]() |
2985562c2f | ||
![]() |
754f850e95 | ||
![]() |
dccb85d225 | ||
![]() |
a0e401bc87 | ||
![]() |
c6885a2124 | ||
![]() |
7528fb7d9b | ||
![]() |
e7df5a299c | ||
![]() |
ff997bbce5 | ||
![]() |
1e21e00e1a | ||
![]() |
77d3ee98f9 | ||
![]() |
1f861b2c90 | ||
![]() |
14a00e67b4 | ||
![]() |
14f63c168d | ||
![]() |
e70dbb3d32 | ||
![]() |
b679275a68 | ||
![]() |
0c1478a67e | ||
![]() |
d26e2346a2 | ||
![]() |
9a09c841b9 | ||
![]() |
f1d4f5a733 | ||
![]() |
d970dd4c89 | ||
![]() |
f3279bf849 | ||
![]() |
db0878a495 | ||
![]() |
c9b1042791 | ||
![]() |
cd81320d8f | ||
![]() |
3046971064 | ||
![]() |
30498f97c4 | ||
![]() |
d9d68efa55 | ||
![]() |
4125dc7ad0 | ||
![]() |
13600894fb | ||
![]() |
1b796cd871 | ||
![]() |
e7889dc12e | ||
![]() |
244a3b1000 | ||
![]() |
05dfda469f | ||
![]() |
6b19ee792d | ||
![]() |
ace38d744a | ||
![]() |
56a5ed8c87 | ||
![]() |
60e8a76476 | ||
![]() |
552800ceb7 | ||
![]() |
7dd1900f5f | ||
![]() |
35c261d0ed | ||
![]() |
fa34ce64b7 | ||
![]() |
f0504420a9 | ||
![]() |
8666f3a46c | ||
![]() |
60d6019cf7 | ||
![]() |
173daeeb09 | ||
![]() |
cf988dca4d | ||
![]() |
ffc2faabf7 | ||
![]() |
9fed0334c8 | ||
![]() |
8b61eb7347 | ||
![]() |
9cdda101c7 | ||
![]() |
f3bbca80ea | ||
![]() |
ce30f28449 | ||
![]() |
6cb58c17e7 | ||
![]() |
183e244490 | ||
![]() |
d5cd5115a5 | ||
![]() |
bbd3b22490 | ||
![]() |
e02daf01ad | ||
![]() |
af1e253f8a | ||
![]() |
491da69994 | ||
![]() |
0737600d3c | ||
![]() |
c7f542e79e | ||
![]() |
21213c97c6 | ||
![]() |
b36cd92ae6 | ||
![]() |
094ac451c7 | ||
![]() |
fa4b666693 | ||
![]() |
ce9dc2093c | ||
![]() |
9fd97a8d63 | ||
![]() |
2261a0e21d | ||
![]() |
a7a1c32a03 | ||
![]() |
dfd01bbf5f | ||
![]() |
b11a5be781 | ||
![]() |
8b6950055b | ||
![]() |
e8a298be00 | ||
![]() |
69f24acac2 | ||
![]() |
9ffebd0c5e | ||
![]() |
2dd3d3c448 | ||
![]() |
4644e7019e | ||
![]() |
5a15d7a219 | ||
![]() |
788129da12 | ||
![]() |
cac5175c9b | ||
![]() |
80556360ac | ||
![]() |
3dca0df55f | ||
![]() |
62a5e9dbce | ||
![]() |
45fcdc75c0 | ||
![]() |
f1bdf6247a | ||
![]() |
80932a51f4 | ||
![]() |
c8774c44d4 | ||
![]() |
bf2629450c | ||
![]() |
705ff78715 | ||
![]() |
a13119a79f | ||
![]() |
6932719e4e | ||
![]() |
68a750fc7a | ||
![]() |
c6d05d0840 | ||
![]() |
2bbfd75f4d | ||
![]() |
26f0e8ea5c | ||
![]() |
552e5caa11 | ||
![]() |
7753187e51 | ||
![]() |
bddadc7522 | ||
![]() |
195eea55f3 | ||
![]() |
7a2794af7c | ||
![]() |
fa48620076 | ||
![]() |
e4cfe01c4a | ||
![]() |
b35e506220 | ||
![]() |
dd3ed1bf75 | ||
![]() |
40368b8f55 | ||
![]() |
d0f1520642 | ||
![]() |
28c8265c3d | ||
![]() |
1d1a8ba78b | ||
![]() |
a1c764593c | ||
![]() |
06902afa2d | ||
![]() |
6d46f10cfa | ||
![]() |
b71f34eb3c | ||
![]() |
11df935f34 | ||
![]() |
19b6468889 | ||
![]() |
d2dddd6c82 | ||
![]() |
5d140fb889 | ||
![]() |
2bf8683905 | ||
![]() |
2dba7f4f61 | ||
![]() |
2820ba319f | ||
![]() |
be7a627c11 | ||
![]() |
2cb1618937 | ||
![]() |
c9e0c5fe04 | ||
![]() |
922956def2 | ||
![]() |
c6c699ea89 | ||
![]() |
e0219d0363 | ||
![]() |
f7dab558e4 | ||
![]() |
74e558dad2 | ||
![]() |
96269fac0f | ||
![]() |
a0501c6ee4 | ||
![]() |
ea2ed75ab2 | ||
![]() |
fc6435825c | ||
![]() |
b3ab48eb68 | ||
![]() |
a212151c09 | ||
![]() |
67ccfc7eb7 | ||
![]() |
9af103c673 | ||
![]() |
82643adfb6 | ||
![]() |
74df94d15a | ||
![]() |
da1b9bdd80 | ||
![]() |
18675ef6df | ||
![]() |
bf9dea5522 | ||
![]() |
62e30c1d79 | ||
![]() |
1316196542 | ||
![]() |
1a377bd03a | ||
![]() |
66a99ce881 | ||
![]() |
481debcb80 | ||
![]() |
03c25b5cac | ||
![]() |
26c060d2c5 | ||
![]() |
7ff42f9b55 | ||
![]() |
a35d8a6262 | ||
![]() |
8f39e1f8f9 | ||
![]() |
ff19b799c4 | ||
![]() |
e547949aee | ||
![]() |
31be00b49f | ||
![]() |
4533d96002 | ||
![]() |
7f89f1a2a0 | ||
![]() |
aed29e1db8 | ||
![]() |
49bee25820 | ||
![]() |
838c8eb057 | ||
![]() |
be5860822d | ||
![]() |
5a10d304c9 | ||
![]() |
fdd3746f54 | ||
![]() |
4d55a48a79 | ||
![]() |
b2ece48239 | ||
![]() |
6375ba30b7 | ||
![]() |
f565f8ac53 | ||
![]() |
5ec05822f1 | ||
![]() |
335b47d7c1 | ||
![]() |
f922561003 | ||
![]() |
79df83f0d3 | ||
![]() |
29416463ff | ||
![]() |
dd2e1ef758 | ||
![]() |
a9b8542ec7 | ||
![]() |
a4ae2ec2d8 | ||
![]() |
b54bfad8c2 | ||
![]() |
724bf7c4ce | ||
![]() |
fccc954fb4 | ||
![]() |
74385a6906 | ||
![]() |
dd66fe63c0 | ||
![]() |
e74934cb17 | ||
![]() |
450281a90a | ||
![]() |
6e7fc0574e | ||
![]() |
fc49aac02b | ||
![]() |
097d883905 | ||
![]() |
cb55118f70 | ||
![]() |
2a3c87945e | ||
![]() |
2b2aacedc6 | ||
![]() |
8ebec52827 | ||
![]() |
1642cc30c8 | ||
![]() |
1645d8f0c0 | ||
![]() |
8d390819a1 | ||
![]() |
c7dd18bb03 | ||
![]() |
84b7de4d21 | ||
![]() |
161df53143 | ||
![]() |
1cfd6cf12e | ||
![]() |
d40dcc35fb | ||
![]() |
a570e95602 | ||
![]() |
e4e43521ee | ||
![]() |
1b2c21a99c | ||
![]() |
e28eda6386 | ||
![]() |
39c171cce7 | ||
![]() |
c81cefd768 | ||
![]() |
325f137265 | ||
![]() |
1ae795df18 | ||
![]() |
2aacd5e28b | ||
![]() |
6e1425e2c0 | ||
![]() |
010db6ce72 | ||
![]() |
ce8d782220 | ||
![]() |
90c2b23fc0 | ||
![]() |
32685aeac1 | ||
![]() |
01c5608104 | ||
![]() |
a35f6298f0 | ||
![]() |
8955d6aed4 | ||
![]() |
cafbf8b990 | ||
![]() |
f626d2f6e5 |
@@ -1,4 +1,4 @@
|
||||
[run]
|
||||
omit =
|
||||
jupyterhub/tests/*
|
||||
jupyterhub/singleuser.py
|
||||
jupyterhub/alembic/*
|
||||
|
29
.github/issue_template.md
vendored
Normal file
29
.github/issue_template.md
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
Hi! Thanks for using JupyterHub.
|
||||
|
||||
If you are reporting an issue with JupyterHub:
|
||||
|
||||
- Please use the [GitHub issue](https://github.com/jupyterhub/jupyterhub/issues)
|
||||
search feature to check if your issue has been asked already. If it has,
|
||||
please add your comments to the existing issue.
|
||||
|
||||
- Where applicable, please fill out the details below to help us troubleshoot
|
||||
the issue that you are facing. Please be as thorough as you are able to
|
||||
provide details on the issue.
|
||||
|
||||
**How to reproduce the issue**
|
||||
|
||||
**What you expected to happen**
|
||||
|
||||
**What actually happens**
|
||||
|
||||
**Share what version of JupyterHub you are using**
|
||||
|
||||
Running `jupyter troubleshoot` from the command line, if possible, and posting
|
||||
its output would also be helpful.
|
||||
|
||||
```
|
||||
|
||||
Insert jupyter troubleshoot output here
|
||||
|
||||
|
||||
```
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,10 +1,12 @@
|
||||
node_modules
|
||||
*.py[co]
|
||||
*~
|
||||
.cache
|
||||
.DS_Store
|
||||
build
|
||||
dist
|
||||
docs/_build
|
||||
docs/source/_static/rest-api
|
||||
.ipynb_checkpoints
|
||||
# ignore config file at the top-level of the repo
|
||||
# but not sub-dirs
|
||||
|
@@ -2,6 +2,7 @@
|
||||
language: python
|
||||
sudo: false
|
||||
python:
|
||||
- 3.6-dev
|
||||
- 3.5
|
||||
- 3.4
|
||||
- 3.3
|
||||
@@ -10,8 +11,12 @@ before_install:
|
||||
- npm install -g configurable-http-proxy
|
||||
- git clone --quiet --depth 1 https://github.com/minrk/travis-wheels travis-wheels
|
||||
install:
|
||||
- pip install -f travis-wheels/wheelhouse -r dev-requirements.txt .
|
||||
- pip install --pre -f travis-wheels/wheelhouse -r dev-requirements.txt .
|
||||
script:
|
||||
- py.test --cov jupyterhub jupyterhub/tests -v
|
||||
- travis_retry py.test --cov jupyterhub jupyterhub/tests -v
|
||||
after_success:
|
||||
- codecov
|
||||
matrix:
|
||||
include:
|
||||
- python: 3.5
|
||||
env: JUPYTERHUB_TEST_SUBDOMAIN_HOST=http://127.0.0.1.xip.io:8000
|
||||
|
26
CHECKLIST-Release.md
Normal file
26
CHECKLIST-Release.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Release checklist
|
||||
|
||||
- [ ] Upgrade Docs prior to Release
|
||||
|
||||
- [ ] Change log
|
||||
- [ ] New features documented
|
||||
- [ ] Update the contributor list - thank you page
|
||||
|
||||
- [ ] Upgrade and test Reference Deployments
|
||||
|
||||
- [ ] Release software
|
||||
|
||||
- [ ] Make sure 0 issues in milestone
|
||||
- [ ] Follow release process steps
|
||||
- [ ] Send builds to PyPI (Warehouse) and Conda Forge
|
||||
|
||||
- [ ] Blog post and/or release note
|
||||
|
||||
- [ ] Notify users of release
|
||||
|
||||
- [ ] Email Jupyter and Jupyter In Education mailing lists
|
||||
- [ ] Tweet (optional)
|
||||
|
||||
- [ ] Increment the version number for the next release
|
||||
|
||||
- [ ] Update roadmap
|
58
Dockerfile
58
Dockerfile
@@ -1,19 +1,36 @@
|
||||
# A base docker image that includes juptyerhub and IPython master
|
||||
# An incomplete base Docker image for running JupyterHub
|
||||
#
|
||||
# Build your own derivative images starting with
|
||||
# Add your configuration to create a complete derivative Docker image.
|
||||
#
|
||||
# FROM jupyter/jupyterhub:latest
|
||||
# Include your configuration settings by starting with one of two options:
|
||||
#
|
||||
# Option 1:
|
||||
#
|
||||
# FROM jupyterhub/jupyterhub:latest
|
||||
#
|
||||
# And put your configuration file jupyterhub_config.py in /srv/jupyterhub/jupyterhub_config.py.
|
||||
#
|
||||
# Option 2:
|
||||
#
|
||||
# Or you can create your jupyterhub config and database on the host machine, and mount it with:
|
||||
#
|
||||
# docker run -v $PWD:/srv/jupyterhub -t jupyterhub/jupyterhub
|
||||
#
|
||||
# NOTE
|
||||
# If you base on jupyterhub/jupyterhub-onbuild
|
||||
# your jupyterhub_config.py will be added automatically
|
||||
# from your docker directory.
|
||||
|
||||
FROM debian:jessie
|
||||
|
||||
MAINTAINER Jupyter Project <jupyter@googlegroups.com>
|
||||
|
||||
# install nodejs, utf8 locale
|
||||
# install nodejs, utf8 locale, set CDN because default httpredir is unreliable
|
||||
ENV DEBIAN_FRONTEND noninteractive
|
||||
RUN apt-get -y update && \
|
||||
RUN REPO=http://cdn-fastly.deb.debian.org && \
|
||||
echo "deb $REPO/debian jessie main\ndeb $REPO/debian-security jessie/updates main" > /etc/apt/sources.list && \
|
||||
apt-get -y update && \
|
||||
apt-get -y upgrade && \
|
||||
apt-get -y install npm nodejs nodejs-legacy wget locales git &&\
|
||||
apt-get -y install wget locales git bzip2 &&\
|
||||
/usr/sbin/update-locale LANG=C.UTF-8 && \
|
||||
locale-gen C.UTF-8 && \
|
||||
apt-get remove -y locales && \
|
||||
@@ -21,30 +38,25 @@ RUN apt-get -y update && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
ENV LANG C.UTF-8
|
||||
|
||||
# install Python with conda
|
||||
RUN wget -q https://repo.continuum.io/miniconda/Miniconda3-3.9.1-Linux-x86_64.sh -O /tmp/miniconda.sh && \
|
||||
# install Python + NodeJS with conda
|
||||
RUN wget -q https://repo.continuum.io/miniconda/Miniconda3-4.2.12-Linux-x86_64.sh -O /tmp/miniconda.sh && \
|
||||
echo 'd0c7c71cc5659e54ab51f2005a8d96f3 */tmp/miniconda.sh' | md5sum -c - && \
|
||||
bash /tmp/miniconda.sh -f -b -p /opt/conda && \
|
||||
/opt/conda/bin/conda install --yes python=3.5 sqlalchemy tornado jinja2 traitlets requests pip && \
|
||||
/opt/conda/bin/conda install --yes -c conda-forge python=3.5 sqlalchemy tornado jinja2 traitlets requests pip nodejs configurable-http-proxy && \
|
||||
/opt/conda/bin/pip install --upgrade pip && \
|
||||
rm /tmp/miniconda.sh
|
||||
ENV PATH=/opt/conda/bin:$PATH
|
||||
|
||||
# install js dependencies
|
||||
RUN npm install -g configurable-http-proxy && rm -rf ~/.npm
|
||||
|
||||
WORKDIR /srv/
|
||||
ADD . /srv/jupyterhub
|
||||
WORKDIR /srv/jupyterhub/
|
||||
ADD . /src/jupyterhub
|
||||
WORKDIR /src/jupyterhub
|
||||
|
||||
RUN python setup.py js && pip install . && \
|
||||
rm -rf node_modules ~/.cache ~/.npm
|
||||
rm -rf $PWD ~/.cache ~/.npm
|
||||
|
||||
RUN mkdir -p /srv/jupyterhub/
|
||||
WORKDIR /srv/jupyterhub/
|
||||
|
||||
# Derivative containers should add jupyterhub config,
|
||||
# which will be used when starting the application.
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ONBUILD ADD jupyterhub_config.py /srv/jupyterhub/jupyterhub_config.py
|
||||
CMD ["jupyterhub", "-f", "/srv/jupyterhub/jupyterhub_config.py"]
|
||||
LABEL org.jupyter.service="jupyterhub"
|
||||
|
||||
CMD ["jupyterhub"]
|
||||
|
@@ -4,13 +4,16 @@ include setupegg.py
|
||||
include bower.json
|
||||
include package.json
|
||||
include *requirements.txt
|
||||
include Dockerfile
|
||||
|
||||
graft onbuild
|
||||
graft jupyterhub
|
||||
graft scripts
|
||||
graft share
|
||||
|
||||
# Documentation
|
||||
graft docs
|
||||
prune docs/node_modules
|
||||
|
||||
# prune some large unused files from components
|
||||
prune share/jupyter/hub/static/components/bootstrap/css
|
||||
|
248
README.md
248
README.md
@@ -1,72 +1,174 @@
|
||||
# JupyterHub: A multi-user server for Jupyter notebooks
|
||||
**[Technical overview](#technical-overview)** |
|
||||
**[Prerequisites](#prerequisites)** |
|
||||
**[Installation](#installation)** |
|
||||
**[Running the Hub Server](#running-the-hub-server)** |
|
||||
**[Configuration](#configuration)** |
|
||||
**[Docker](#docker)** |
|
||||
**[Contributing](#contributing)** |
|
||||
**[License](#license)** |
|
||||
**[Getting help](#getting-help)**
|
||||
|
||||
Questions, comments? Visit our Google Group:
|
||||
# [JupyterHub](https://github.com/jupyterhub/jupyterhub)
|
||||
|
||||
[](https://groups.google.com/forum/#!forum/jupyter)
|
||||
[](https://travis-ci.org/jupyter/jupyterhub)
|
||||
[](https://circleci.com/gh/jupyter/jupyterhub)
|
||||
[](https://travis-ci.org/jupyterhub/jupyterhub)
|
||||
[](https://circleci.com/gh/jupyterhub/jupyterhub)
|
||||
[](https://codecov.io/github/jupyterhub/jupyterhub?branch=master)
|
||||
"
|
||||
[](http://jupyterhub.readthedocs.org/en/latest/?badge=latest)
|
||||
"
|
||||
[](https://groups.google.com/forum/#!forum/jupyter)
|
||||
|
||||
JupyterHub, a multi-user server, manages and proxies multiple instances of the single-user <del>IPython</del> Jupyter notebook server.
|
||||
With [JupyterHub](https://jupyterhub.readthedocs.io) you can create a
|
||||
**multi-user Hub** which spawns, manages, and proxies multiple instances of the
|
||||
single-user [Jupyter notebook *(IPython notebook)* ](https://jupyter-notebook.readthedocs.io) server.
|
||||
|
||||
Three actors:
|
||||
JupyterHub provides **single-user notebook servers to many users**. For example,
|
||||
JupyterHub could serve notebooks to a class of students, a corporate
|
||||
workgroup, or a science research group.
|
||||
|
||||
- multi-user Hub (tornado process)
|
||||
- configurable http proxy (node-http-proxy)
|
||||
- multiple single-user IPython notebook servers (Python/IPython/tornado)
|
||||
by [Project Jupyter](https://jupyter.org)
|
||||
|
||||
Basic principles:
|
||||
----
|
||||
|
||||
- Hub spawns proxy
|
||||
- Proxy forwards ~all requests to hub by default
|
||||
## Technical overview
|
||||
Three main actors make up JupyterHub:
|
||||
|
||||
- multi-user **Hub** (tornado process)
|
||||
- configurable http **proxy** (node-http-proxy)
|
||||
- multiple **single-user Jupyter notebook servers** (Python/IPython/tornado)
|
||||
|
||||
JupyterHub's basic principles for operation are:
|
||||
|
||||
- Hub spawns a proxy
|
||||
- Proxy forwards all requests to Hub by default
|
||||
- Hub handles login, and spawns single-user servers on demand
|
||||
- Hub configures proxy to forward url prefixes to single-user servers
|
||||
- Hub configures proxy to forward url prefixes to the single-user servers
|
||||
|
||||
JupyterHub also provides a
|
||||
[REST API](http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyterhub/master/docs/rest-api.yml#/default)
|
||||
for administration of the Hub and users.
|
||||
|
||||
## Dependencies
|
||||
----
|
||||
|
||||
JupyterHub requires [IPython](https://ipython.org/install.html) >= 3.0 (current master) and [Python](https://www.python.org/downloads/) >= 3.3.
|
||||
## Prerequisites
|
||||
Before installing JupyterHub, you need:
|
||||
|
||||
Install [nodejs/npm](https://www.npmjs.com/), which is available from your
|
||||
package manager. For example, install on Linux (Debian/Ubuntu) using:
|
||||
- [Python](https://www.python.org/downloads/) 3.3 or greater
|
||||
|
||||
sudo apt-get install npm nodejs-legacy
|
||||
An understanding of using [`pip`](https://pip.pypa.io/en/stable/) for installing
|
||||
Python packages is recommended.
|
||||
|
||||
(The `nodejs-legacy` package installs the `node` executable and is currently
|
||||
required for npm to work on Debian/Ubuntu.)
|
||||
- [nodejs/npm](https://www.npmjs.com/)
|
||||
|
||||
Next, install JavaScript dependencies:
|
||||
[Install nodejs/npm](https://docs.npmjs.com/getting-started/installing-node), which is available from your
|
||||
package manager. For example, install on Linux (Debian/Ubuntu) using:
|
||||
|
||||
sudo npm install -g configurable-http-proxy
|
||||
sudo apt-get install npm nodejs-legacy
|
||||
|
||||
### (Optional) Installation Prerequisite (pip)
|
||||
(The `nodejs-legacy` package installs the `node` executable and is currently
|
||||
required for npm to work on Debian/Ubuntu.)
|
||||
|
||||
Notes on the `pip` command used in the installation directions below:
|
||||
- `sudo` may be needed for `pip install`, depending on the user's filesystem permissions.
|
||||
- JupyterHub requires Python >= 3.3, so `pip3` may be required on some machines for package installation instead of `pip` (especially when both Python 2 and Python 3 are installed on a machine). If `pip3` is not found, install it using (on Linux Debian/Ubuntu):
|
||||
- TLS certificate and key for HTTPS communication
|
||||
|
||||
sudo apt-get install python3-pip
|
||||
- Domain name
|
||||
|
||||
Before running the single-user notebook servers (which may be on the same system as the Hub or not):
|
||||
|
||||
- [Jupyter Notebook](https://jupyter.readthedocs.io/en/latest/install.html) version 4 or greater
|
||||
|
||||
## Installation
|
||||
JupyterHub can be installed with `pip`, and the proxy with `npm`:
|
||||
|
||||
JupyterHub can be installed with pip:
|
||||
```bash
|
||||
npm install -g configurable-http-proxy
|
||||
pip3 install jupyterhub
|
||||
```
|
||||
|
||||
pip3 install jupyterhub
|
||||
If you plan to run notebook servers locally, you will need to install the
|
||||
Jupyter notebook:
|
||||
|
||||
If you plan to run notebook servers locally, you may also need to install the
|
||||
Jupyter ~~IPython~~ notebook:
|
||||
pip3 install --upgrade notebook
|
||||
|
||||
pip3 install notebook
|
||||
## Running the Hub server
|
||||
To start the Hub server, run the command:
|
||||
|
||||
jupyterhub
|
||||
|
||||
### Development install
|
||||
Visit `https://localhost:8000` in your browser, and sign in with your unix credentials.
|
||||
|
||||
For a development install, clone the repository and then install from source:
|
||||
To allow multiple users to sign into the server, you will need to
|
||||
run the `jupyterhub` command as a *privileged user*, such as root.
|
||||
The [wiki](https://github.com/jupyterhub/jupyterhub/wiki/Using-sudo-to-run-JupyterHub-without-root-privileges)
|
||||
describes how to run the server as a *less privileged user*, which requires more
|
||||
configuration of the system.
|
||||
|
||||
git clone https://github.com/jupyter/jupyterhub
|
||||
cd jupyterhub
|
||||
pip3 install -r dev-requirements.txt -e .
|
||||
----
|
||||
|
||||
## Configuration
|
||||
The [getting started document](docs/source/getting-started.md) contains the
|
||||
basics of configuring a JupyterHub deployment.
|
||||
|
||||
The JupyterHub **tutorial** provides a video and documentation that explains and illustrates the fundamental steps for installation and configuration. [Repo](https://github.com/jupyterhub/jupyterhub-tutorial)
|
||||
| [Tutorial documentation](http://jupyterhub-tutorial.readthedocs.io/en/latest/)
|
||||
|
||||
#### Generate a default configuration file
|
||||
Generate a default config file:
|
||||
|
||||
jupyterhub --generate-config
|
||||
|
||||
#### Customize the configuration, authentication, and process spawning
|
||||
Spawn the server on ``10.0.1.2:443`` with **https**:
|
||||
|
||||
jupyterhub --ip 10.0.1.2 --port 443 --ssl-key my_ssl.key --ssl-cert my_ssl.cert
|
||||
|
||||
The authentication and process spawning mechanisms can be replaced,
|
||||
which should allow plugging into a variety of authentication or process control environments.
|
||||
Some examples, meant as illustration and testing of this concept:
|
||||
|
||||
- Using GitHub OAuth instead of PAM with [OAuthenticator](https://github.com/jupyterhub/oauthenticator)
|
||||
- Spawning single-user servers with Docker, using the [DockerSpawner](https://github.com/jupyterhub/dockerspawner)
|
||||
|
||||
----
|
||||
|
||||
## Docker
|
||||
A starter [docker image for JupyterHub](https://hub.docker.com/r/jupyterhub/jupyterhub/) gives a baseline deployment of JupyterHub.
|
||||
|
||||
**Important:** This `jupyterhub/jupyterhub` image contains only the Hub itself, with no configuration. In general, one needs
|
||||
to make a derivative image, with at least a `jupyterhub_config.py` setting up an Authenticator and/or a Spawner. To run the
|
||||
single-user servers, which may be on the same system as the Hub or not, Jupyter Notebook version 4 or greater must be installed.
|
||||
|
||||
#### Starting JupyterHub with docker
|
||||
The JupyterHub docker image can be started with the following command:
|
||||
|
||||
docker run -d --name jupyterhub jupyterhub/jupyterhub jupyterhub
|
||||
|
||||
This command will create a container named `jupyterhub` that you can **stop and resume** with `docker stop/start`.
|
||||
|
||||
The Hub service will be listening on all interfaces at port 8000, which makes this a good choice for **testing JupyterHub on your desktop or laptop**.
|
||||
|
||||
If you want to run docker on a computer that has a public IP then you should (as in MUST) **secure it with ssl** by
|
||||
adding ssl options to your docker configuration or using a ssl enabled proxy.
|
||||
|
||||
[Mounting volumes](https://docs.docker.com/engine/userguide/containers/dockervolumes/) will
|
||||
allow you to **store data outside the docker image (host system) so it will be persistent**, even when you start
|
||||
a new image.
|
||||
|
||||
The command `docker exec -it jupyterhub bash` will spawn a root shell in your docker
|
||||
container. You can **use the root shell to create system users in the container**. These accounts will be used for authentication
|
||||
in JupyterHub's default configuration.
|
||||
|
||||
----
|
||||
|
||||
## Contributing
|
||||
If you would like to contribute to the project, please read our [contributor documentation](http://jupyter.readthedocs.io/en/latest/contributor/content-contributor.html) and the [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
||||
|
||||
For a **development install**, clone the [repository](https://github.com/jupyterhub/jupyterhub) and then install from source:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/jupyterhub/jupyterhub
|
||||
cd jupyterhub
|
||||
pip3 install -r dev-requirements.txt -e .
|
||||
```
|
||||
|
||||
If the `pip3 install` command fails and complains about `lessc` being unavailable, you may need to explicitly install some additional JavaScript dependencies:
|
||||
|
||||
@@ -76,59 +178,33 @@ This will fetch client-side JavaScript dependencies necessary to compile CSS.
|
||||
|
||||
You may also need to manually update JavaScript and CSS after some development updates, with:
|
||||
|
||||
python3 setup.py js # fetch updated client-side js (changes rarely)
|
||||
python3 setup.py css # recompile CSS from LESS sources
|
||||
```bash
|
||||
python3 setup.py js # fetch updated client-side js
|
||||
python3 setup.py css # recompile CSS from LESS sources
|
||||
```
|
||||
|
||||
We use [pytest](http://doc.pytest.org/en/latest/) for testing. To run tests:
|
||||
|
||||
## Running the server
|
||||
```bash
|
||||
pytest jupyterhub/tests
|
||||
```
|
||||
|
||||
To start the server, run the command:
|
||||
----
|
||||
## License
|
||||
We use a shared copyright model that enables all contributors to maintain the
|
||||
copyright on their contributions.
|
||||
|
||||
jupyterhub
|
||||
All code is licensed under the terms of the revised BSD license.
|
||||
|
||||
and then visit `http://localhost:8000`, and sign in with your unix credentials.
|
||||
|
||||
To allow multiple users to sign into the server, you will need to
|
||||
run the `jupyterhub` command as a *privileged user*, such as root.
|
||||
The [wiki](https://github.com/jupyter/jupyterhub/wiki/Using-sudo-to-run-JupyterHub-without-root-privileges)
|
||||
describes how to run the server as a *less privileged user*, which requires more
|
||||
configuration of the system.
|
||||
|
||||
## Getting started
|
||||
|
||||
See the [getting started document](docs/source/getting-started.md) for the
|
||||
basics of configuring your JupyterHub deployment.
|
||||
|
||||
### Some examples
|
||||
|
||||
Generate a default config file:
|
||||
|
||||
jupyterhub --generate-config
|
||||
|
||||
Spawn the server on ``10.0.1.2:443`` with **https**:
|
||||
|
||||
jupyterhub --ip 10.0.1.2 --port 443 --ssl-key my_ssl.key --ssl-cert my_ssl.cert
|
||||
|
||||
The authentication and process spawning mechanisms can be replaced,
|
||||
which should allow plugging into a variety of authentication or process control environments.
|
||||
Some examples, meant as illustration and testing of this concept:
|
||||
|
||||
- Using GitHub OAuth instead of PAM with [OAuthenticator](https://github.com/jupyter/oauthenticator)
|
||||
- Spawning single-user servers with Docker, using the [DockerSpawner](https://github.com/jupyter/dockerspawner)
|
||||
|
||||
# Getting help
|
||||
|
||||
We encourage you to ask questions on the mailing list:
|
||||
|
||||
[](https://groups.google.com/forum/#!forum/jupyter)
|
||||
|
||||
and you may participate in development discussions or get live help on Gitter:
|
||||
|
||||
[](https://gitter.im/jupyter/jupyterhub?utm_source=badge&utm_medium=badge)
|
||||
## Getting help
|
||||
We encourage you to ask questions on the [mailing list](https://groups.google.com/forum/#!forum/jupyter),
|
||||
and you may participate in development discussions or get live help on [Gitter](https://gitter.im/jupyterhub/jupyterhub).
|
||||
|
||||
## Resources
|
||||
- [Reporting Issues](https://github.com/jupyterhub/jupyterhub/issues)
|
||||
- JupyterHub tutorial | [Repo](https://github.com/jupyterhub/jupyterhub-tutorial)
|
||||
| [Tutorial documentation](http://jupyterhub-tutorial.readthedocs.io/en/latest/)
|
||||
- [Documentation for JupyterHub](http://jupyterhub.readthedocs.io/en/latest/) | [PDF (latest)](https://media.readthedocs.org/pdf/jupyterhub/latest/jupyterhub.pdf) | [PDF (stable)](https://media.readthedocs.org/pdf/jupyterhub/stable/jupyterhub.pdf)
|
||||
- [Documentation for JupyterHub's REST API](http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyterhub/master/docs/rest-api.yml#/default)
|
||||
- [Documentation for Project Jupyter](http://jupyter.readthedocs.io/en/latest/index.html) | [PDF](https://media.readthedocs.org/pdf/jupyter/latest/jupyter.pdf)
|
||||
- [Project Jupyter website](https://jupyter.org)
|
||||
- [Documentation for JupyterHub](http://jupyterhub.readthedocs.org/en/latest/) [[PDF](https://media.readthedocs.org/pdf/jupyterhub/latest/jupyterhub.pdf)]
|
||||
- [Documentation for Project Jupyter](http://jupyter.readthedocs.org/en/latest/index.html) [[PDF](https://media.readthedocs.org/pdf/jupyter/latest/jupyter.pdf)]
|
||||
- [Issues](https://github.com/jupyter/jupyterhub/issues)
|
||||
- [Technical support - Jupyter Google Group](https://groups.google.com/forum/#!forum/jupyter)
|
||||
|
15
circle.yml
15
circle.yml
@@ -8,4 +8,17 @@ dependencies:
|
||||
|
||||
test:
|
||||
override:
|
||||
- docker build -t jupyter/jupyterhub .
|
||||
- docker build -t jupyterhub/jupyterhub .
|
||||
- docker build -t jupyterhub/jupyterhub-onbuild:${CIRCLE_TAG:-latest} onbuild
|
||||
|
||||
deployment:
|
||||
hub:
|
||||
branch: master
|
||||
commands:
|
||||
- docker login -u $DOCKER_USER -p $DOCKER_PASS -e unused@example.com
|
||||
- docker push jupyterhub/jupyterhub-onbuild
|
||||
release:
|
||||
tag: /.*/
|
||||
commands:
|
||||
- docker login -u $DOCKER_USER -p $DOCKER_PASS -e unused@example.com
|
||||
- docker push jupyterhub/jupyterhub-onbuild:$CIRCLE_TAG
|
||||
|
@@ -1,5 +1,7 @@
|
||||
-r requirements.txt
|
||||
mock
|
||||
codecov
|
||||
pytest-cov
|
||||
pytest>=2.8
|
||||
notebook
|
||||
requests-mock
|
||||
|
@@ -47,11 +47,20 @@ help:
|
||||
@echo " linkcheck to check all external links for integrity"
|
||||
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
|
||||
@echo " coverage to run coverage check of the documentation (if enabled)"
|
||||
@echo " spelling to run spell check on documentation"
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILDDIR)/*
|
||||
|
||||
html:
|
||||
node_modules: package.json
|
||||
npm install && touch node_modules
|
||||
|
||||
rest-api: source/_static/rest-api/index.html
|
||||
|
||||
source/_static/rest-api/index.html: rest-api.yml node_modules
|
||||
npm run rest-api
|
||||
|
||||
html: rest-api
|
||||
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
|
||||
@echo
|
||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
|
||||
@@ -171,6 +180,11 @@ linkcheck:
|
||||
@echo "Link check complete; look for any errors in the above output " \
|
||||
"or in $(BUILDDIR)/linkcheck/output.txt."
|
||||
|
||||
spelling:
|
||||
$(SPHINXBUILD) -b spelling $(ALLSPHINXOPTS) $(BUILDDIR)/spelling
|
||||
@echo
|
||||
@echo "Spell check complete; look for any errors in the above output " \
|
||||
"or in $(BUILDDIR)/spelling/output.txt."
|
||||
doctest:
|
||||
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
|
||||
@echo "Testing of doctests in the sources finished, look at the " \
|
||||
|
16
docs/environment.yml
Normal file
16
docs/environment.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
name: jhub_docs
|
||||
channels:
|
||||
- conda-forge
|
||||
dependencies:
|
||||
- nodejs
|
||||
- python=3
|
||||
- jinja2
|
||||
- pamela
|
||||
- requests
|
||||
- sqlalchemy>=1
|
||||
- tornado>=4.1
|
||||
- traitlets>=4.1
|
||||
- sphinx>=1.3.6
|
||||
- sphinx_rtd_theme
|
||||
- pip:
|
||||
- recommonmark==0.4.0
|
14
docs/package.json
Normal file
14
docs/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "jupyterhub-docs-build",
|
||||
"version": "0.0.0",
|
||||
"description": "build JupyterHub swagger docs",
|
||||
"scripts": {
|
||||
"rest-api": "bootprint openapi ./rest-api.yml source/_static/rest-api"
|
||||
},
|
||||
"author": "",
|
||||
"license": "BSD-3-Clause",
|
||||
"devDependencies": {
|
||||
"bootprint": "^0.10.0",
|
||||
"bootprint-openapi": "^0.17.0"
|
||||
}
|
||||
}
|
@@ -1,3 +1,3 @@
|
||||
-r ../requirements.txt
|
||||
sphinx
|
||||
sphinx>=1.3.6
|
||||
recommonmark==0.4.0
|
@@ -3,9 +3,11 @@ swagger: '2.0'
|
||||
info:
|
||||
title: JupyterHub
|
||||
description: The REST API for JupyterHub
|
||||
version: 0.4.0
|
||||
version: 0.7.0
|
||||
license:
|
||||
name: BSD-3-Clause
|
||||
schemes:
|
||||
- http
|
||||
- [http, https]
|
||||
securityDefinitions:
|
||||
token:
|
||||
type: apiKey
|
||||
@@ -13,18 +15,73 @@ securityDefinitions:
|
||||
in: header
|
||||
security:
|
||||
- token: []
|
||||
basePath: /hub/api/
|
||||
basePath: /hub/api
|
||||
produces:
|
||||
- application/json
|
||||
consumes:
|
||||
- application/json
|
||||
paths:
|
||||
/:
|
||||
get:
|
||||
summary: Get JupyterHub version
|
||||
description: |
|
||||
This endpoint is not authenticated for the purpose of clients and user
|
||||
to identify the JupyterHub version before setting up authentication.
|
||||
responses:
|
||||
'200':
|
||||
description: The JupyterHub version
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
description: The version of JupyterHub itself
|
||||
/info:
|
||||
get:
|
||||
summary: Get detailed info about JupyterHub
|
||||
description: |
|
||||
Detailed JupyterHub information, including Python version,
|
||||
JupyterHub's version and executable path,
|
||||
and which Authenticator and Spawner are active.
|
||||
responses:
|
||||
'200':
|
||||
description: Detailed JupyterHub info
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
description: The version of JupyterHub itself
|
||||
python:
|
||||
type: string
|
||||
description: The Python version, as returned by sys.version
|
||||
sys_executable:
|
||||
type: string
|
||||
description: The path to sys.executable running JupyterHub
|
||||
authenticator:
|
||||
type: object
|
||||
properties:
|
||||
class:
|
||||
type: string
|
||||
description: The Python class currently active for JupyterHub Authentication
|
||||
version:
|
||||
type: string
|
||||
description: The version of the currently active Authenticator
|
||||
spawner:
|
||||
type: object
|
||||
properties:
|
||||
class:
|
||||
type: string
|
||||
description: The Python class currently active for spawning single-user notebook servers
|
||||
version:
|
||||
type: string
|
||||
description: The version of the currently active Spawner
|
||||
/users:
|
||||
get:
|
||||
summary: List users
|
||||
responses:
|
||||
'200':
|
||||
description: The user list
|
||||
description: The Hub's user list
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
@@ -40,7 +97,7 @@ paths:
|
||||
properties:
|
||||
usernames:
|
||||
type: array
|
||||
description: list of usernames to create
|
||||
description: list of usernames to create on the Hub
|
||||
items:
|
||||
type: string
|
||||
admin:
|
||||
@@ -81,17 +138,6 @@ paths:
|
||||
description: The user has been created
|
||||
schema:
|
||||
$ref: '#/definitions/User'
|
||||
delete:
|
||||
summary: Delete a user
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: The user has been deleted
|
||||
patch:
|
||||
summary: Modify a user
|
||||
description: Change a user's name or admin status
|
||||
@@ -104,24 +150,35 @@ paths:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
description: Updated user info. At least one of name and admin is required.
|
||||
description: Updated user info. At least one key to be updated (name or admin) is required.
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: the new name (optional)
|
||||
description: the new name (optional, if another key is updated i.e. admin)
|
||||
admin:
|
||||
type: boolean
|
||||
description: update admin (optional)
|
||||
description: update admin (optional, if another key is updated i.e. name)
|
||||
responses:
|
||||
'200':
|
||||
description: The updated user info
|
||||
schema:
|
||||
$ref: '#/definitions/User'
|
||||
delete:
|
||||
summary: Delete a user
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: The user has been deleted
|
||||
/users/{name}/server:
|
||||
post:
|
||||
summary: Start a user's server
|
||||
summary: Start a user's single-user notebook server
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
@@ -130,9 +187,9 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
'201':
|
||||
description: The server has started
|
||||
description: The user's notebook server has started
|
||||
'202':
|
||||
description: The server has been requested, but has not yet started
|
||||
description: The user's notebook server has not yet started, but has been requested
|
||||
delete:
|
||||
summary: Stop a user's server
|
||||
parameters:
|
||||
@@ -143,12 +200,12 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: The server has stopped
|
||||
description: The user's notebook server has stopped
|
||||
'202':
|
||||
description: The server has been asked to stop, but is taking a while
|
||||
description: The user's notebook server has not yet stopped as it is taking a while to stop
|
||||
/users/{name}/admin-access:
|
||||
post:
|
||||
summary: Grant an admin access to this user's server
|
||||
summary: Grant admin access to this user's notebook server
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
@@ -157,25 +214,146 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Sets a cookie granting the requesting admin access to the user's server
|
||||
description: Sets a cookie granting the requesting administrator access to the user's notebook server
|
||||
/groups:
|
||||
get:
|
||||
summary: List groups
|
||||
responses:
|
||||
'200':
|
||||
description: The list of groups
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/Group'
|
||||
/groups/{name}:
|
||||
get:
|
||||
summary: Get a group by name
|
||||
parameters:
|
||||
- name: name
|
||||
description: group name
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: The group model
|
||||
schema:
|
||||
$ref: '#/definitions/Group'
|
||||
post:
|
||||
summary: Create a group
|
||||
parameters:
|
||||
- name: name
|
||||
description: group name
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
'201':
|
||||
description: The group has been created
|
||||
schema:
|
||||
$ref: '#/definitions/Group'
|
||||
delete:
|
||||
summary: Delete a group
|
||||
parameters:
|
||||
- name: name
|
||||
description: group name
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: The group has been deleted
|
||||
/groups/{name}/users:
|
||||
post:
|
||||
summary: Add users to a group
|
||||
parameters:
|
||||
- name: name
|
||||
description: group name
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
description: The users to add to the group
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
users:
|
||||
type: array
|
||||
description: List of usernames to add to the group
|
||||
items:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: The users have been added to the group
|
||||
schema:
|
||||
$ref: '#/definitions/Group'
|
||||
delete:
|
||||
summary: Remove users from a group
|
||||
parameters:
|
||||
- name: name
|
||||
description: group name
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
description: The users to remove from the group
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
users:
|
||||
type: array
|
||||
description: List of usernames to remove from the group
|
||||
items:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: The users have been removed from the group
|
||||
/services:
|
||||
get:
|
||||
summary: List services
|
||||
responses:
|
||||
'200':
|
||||
description: The service list
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/Service'
|
||||
/services/{name}:
|
||||
get:
|
||||
summary: Get a service by name
|
||||
parameters:
|
||||
- name: name
|
||||
description: service name
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: The Service model
|
||||
schema:
|
||||
$ref: '#/definitions/Service'
|
||||
/proxy:
|
||||
get:
|
||||
summary: Get the proxy's routing table
|
||||
description: A convenience alias for getting the info directly from the proxy
|
||||
description: A convenience alias for getting the routing table directly from the proxy
|
||||
responses:
|
||||
'200':
|
||||
description: Routing table
|
||||
schema:
|
||||
type: object
|
||||
description: configurable-http-proxy routing table (see CHP docs for details)
|
||||
description: configurable-http-proxy routing table (see configurable-http-proxy docs for details)
|
||||
post:
|
||||
summary: Force the Hub to sync with the proxy
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
patch:
|
||||
summary: Tell the Hub about a new proxy
|
||||
description: If you have started a new proxy and would like the Hub to switch over to it, this allows you to notify the Hub of the new proxy.
|
||||
summary: Notify the Hub about a new proxy
|
||||
description: Notifies the Hub of a new proxy to use.
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
@@ -211,11 +389,11 @@ paths:
|
||||
'200':
|
||||
description: The user identified by the API token
|
||||
schema:
|
||||
$ref: '#!/definitions/User'
|
||||
$ref: '#/definitions/User'
|
||||
/authorizations/cookie/{cookie_name}/{cookie_value}:
|
||||
get:
|
||||
summary: Identify a user from a cookie
|
||||
description: Used by single-user servers to hand off cookie authentication to the Hub
|
||||
description: Used by single-user notebook servers to hand off cookie authentication to the Hub
|
||||
parameters:
|
||||
- name: cookie_name
|
||||
in: path
|
||||
@@ -229,10 +407,19 @@ paths:
|
||||
'200':
|
||||
description: The user identified by the cookie
|
||||
schema:
|
||||
$ref: '#!/definitions/User'
|
||||
$ref: '#/definitions/User'
|
||||
/shutdown:
|
||||
post:
|
||||
summary: Shutdown the Hub
|
||||
parameters:
|
||||
- name: proxy
|
||||
in: body
|
||||
type: boolean
|
||||
description: Whether the proxy should be shutdown as well (default from Hub config)
|
||||
- name: servers
|
||||
in: body
|
||||
type: boolean
|
||||
description: Whether users's notebook servers should be shutdown as well (default from Hub config)
|
||||
responses:
|
||||
'200':
|
||||
description: Hub has shutdown
|
||||
@@ -246,14 +433,53 @@ definitions:
|
||||
admin:
|
||||
type: boolean
|
||||
description: Whether the user is an admin
|
||||
groups:
|
||||
type: array
|
||||
description: The names of groups where this user is a member
|
||||
items:
|
||||
type: string
|
||||
server:
|
||||
type: string
|
||||
description: The user's server's base URL, if running; null if not.
|
||||
description: The user's notebook server's base URL, if running; null if not.
|
||||
pending:
|
||||
type: string
|
||||
enum: ["spawn", "stop"]
|
||||
description: The currently pending action, if any
|
||||
last_activity:
|
||||
type: string
|
||||
format: ISO8601 Timestamp
|
||||
format: date-time
|
||||
description: Timestamp of last-seen activity from the user
|
||||
Group:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: The group's name
|
||||
users:
|
||||
type: array
|
||||
description: The names of users who are members of this group
|
||||
items:
|
||||
type: string
|
||||
Service:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: The service's name
|
||||
admin:
|
||||
type: boolean
|
||||
description: Whether the service is an admin
|
||||
url:
|
||||
type: string
|
||||
description: The internal url where the service is running
|
||||
prefix:
|
||||
type: string
|
||||
description: The proxied URL prefix to the service's url
|
||||
pid:
|
||||
type: number
|
||||
description: The PID of the service process (if managed)
|
||||
command:
|
||||
type: array
|
||||
description: The command used to start the service (if managed)
|
||||
items:
|
||||
type: string
|
||||
|
@@ -7,8 +7,27 @@
|
||||
:Release: |release|
|
||||
:Date: |today|
|
||||
|
||||
JupyterHub also provides a REST API for administration of the Hub and users.
|
||||
The documentation on `Using JupyterHub's REST API <../rest.html>`_ provides
|
||||
information on:
|
||||
|
||||
- Creating an API token
|
||||
- Adding tokens to the configuration file (optional)
|
||||
- Making an API request
|
||||
|
||||
The same JupyterHub API spec, as found here, is available in an interactive form
|
||||
`here (on swagger's petstore) <http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/master/docs/rest-api.yml#!/default>`__.
|
||||
The `OpenAPI Initiative`_ (fka Swagger™) is a project used to describe
|
||||
and document RESTful APIs.
|
||||
|
||||
JupyterHub API Reference:
|
||||
|
||||
.. toctree::
|
||||
|
||||
auth
|
||||
spawner
|
||||
user
|
||||
services.auth
|
||||
|
||||
|
||||
.. _OpenAPI Initiative: https://www.openapis.org/
|
||||
|
18
docs/source/api/services.auth.rst
Normal file
18
docs/source/api/services.auth.rst
Normal file
@@ -0,0 +1,18 @@
|
||||
=======================
|
||||
Authenticating Services
|
||||
=======================
|
||||
|
||||
Module: :mod:`jupyterhub.services.auth`
|
||||
=======================================
|
||||
|
||||
.. automodule:: jupyterhub.services.auth
|
||||
|
||||
.. currentmodule:: jupyterhub.services.auth
|
||||
|
||||
|
||||
.. autoclass:: HubAuth
|
||||
:members:
|
||||
|
||||
.. autoclass:: HubAuthenticated
|
||||
:members:
|
||||
|
@@ -13,6 +13,6 @@ Module: :mod:`jupyterhub.spawner`
|
||||
----------------
|
||||
|
||||
.. autoclass:: Spawner
|
||||
:members: options_from_form, poll, start, stop, get_args, get_env, get_state
|
||||
:members: options_from_form, poll, start, stop, get_args, get_env, get_state, template_namespace, format_string
|
||||
|
||||
.. autoclass:: LocalProcessSpawner
|
||||
|
@@ -1,4 +1,4 @@
|
||||
# Writing a custom Authenticator
|
||||
# Authenticators
|
||||
|
||||
The [Authenticator][] is the mechanism for authorizing users.
|
||||
Basic authenticators use simple username and password authentication.
|
||||
@@ -11,14 +11,13 @@ One such example is using [GitHub OAuth][].
|
||||
Because the username is passed from the Authenticator to the Spawner,
|
||||
a custom Authenticator and Spawner are often used together.
|
||||
|
||||
See a list of custom Authenticators [on the wiki](https://github.com/jupyter/jupyterhub/wiki/Authenticators).
|
||||
See a list of custom Authenticators [on the wiki](https://github.com/jupyterhub/jupyterhub/wiki/Authenticators).
|
||||
|
||||
|
||||
## Basics of Authenticators
|
||||
|
||||
A basic Authenticator has one central method:
|
||||
|
||||
|
||||
### Authenticator.authenticate
|
||||
|
||||
Authenticator.authenticate(handler, data)
|
||||
@@ -48,14 +47,13 @@ class DictionaryAuthenticator(Authenticator):
|
||||
passwords = Dict(config=True,
|
||||
help="""dict of username:password for authentication"""
|
||||
)
|
||||
|
||||
|
||||
@gen.coroutine
|
||||
def authenticate(self, handler, data):
|
||||
if self.passwords.get(data['username']) == data['password']:
|
||||
return data['username']
|
||||
```
|
||||
|
||||
|
||||
### Authenticator.whitelist
|
||||
|
||||
Authenticators can specify a whitelist of usernames to allow authentication.
|
||||
@@ -77,6 +75,7 @@ For simple mappings, a configurable dict `Authenticator.username_map` is used to
|
||||
c.Authenticator.username_map = {
|
||||
'service-name': 'localname'
|
||||
}
|
||||
```
|
||||
|
||||
### Validating usernames
|
||||
|
||||
@@ -90,8 +89,9 @@ which is a regular expression string for validation.
|
||||
|
||||
To only allow usernames that start with 'w':
|
||||
|
||||
c.Authenticator.username_pattern = r'w.*'
|
||||
|
||||
```python
|
||||
c.Authenticator.username_pattern = r'w.*'
|
||||
```
|
||||
|
||||
## OAuth and other non-password logins
|
||||
|
||||
@@ -102,9 +102,12 @@ You can see an example implementation of an Authenticator that uses [GitHub OAut
|
||||
at [OAuthenticator][].
|
||||
|
||||
|
||||
[Authenticator]: https://github.com/jupyter/jupyterhub/blob/master/jupyterhub/auth.py
|
||||
[PAM]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
|
||||
[OAuth]: https://en.wikipedia.org/wiki/OAuth
|
||||
[GitHub OAuth]: https://developer.github.com/v3/oauth/
|
||||
[OAuthenticator]: https://github.com/jupyter/oauthenticator
|
||||
## Writing a custom authenticator
|
||||
|
||||
If you are interested in writing a custom authenticator, you can read [this tutorial](http://jupyterhub-tutorial.readthedocs.io/en/latest/authenticators.html).
|
||||
|
||||
[Authenticator]: https://github.com/jupyterhub/jupyterhub/blob/master/jupyterhub/auth.py
|
||||
[PAM]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
|
||||
[OAuth]: https://en.wikipedia.org/wiki/OAuth
|
||||
[GitHub OAuth]: https://developer.github.com/v3/oauth/
|
||||
[OAuthenticator]: https://github.com/jupyterhub/oauthenticator
|
||||
|
@@ -1,14 +1,108 @@
|
||||
# Summary of changes in JupyterHub
|
||||
# Change log summary
|
||||
|
||||
See `git log` for a more detailed summary.
|
||||
For detailed changes from the prior release, click on the version number, and
|
||||
its link will bring up a GitHub listing of changes. Use `git log` on the
|
||||
command line for details.
|
||||
|
||||
|
||||
## [Unreleased] 0.8
|
||||
|
||||
## 0.7
|
||||
|
||||
### [0.7.1] - 2016-01-02
|
||||
|
||||
#### Added
|
||||
|
||||
- `Spawner.will_resume` for signalling that a single-user server is paused instead of stopped.
|
||||
This is needed for cases like `DockerSpawner.remove_containers = False`,
|
||||
where the first API token is re-used for subsequent spawns.
|
||||
- Warning on startup about single-character usernames,
|
||||
caused by common `set('string')` typo in config.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Removed spurious warning about empty `next_url`, which is AOK.
|
||||
|
||||
### [0.7.0] - 2016-12-2
|
||||
|
||||
#### Added
|
||||
|
||||
- Implement Services API [\#705](https://github.com/jupyterhub/jupyterhub/pull/705)
|
||||
- Add `/api/` and `/api/info` endpoints [\#675](https://github.com/jupyterhub/jupyterhub/pull/675)
|
||||
- Add documentation for JupyterLab, pySpark configuration, troubleshooting,
|
||||
and more.
|
||||
- Add logging of error if adding users already in database. [\#689](https://github.com/jupyterhub/jupyterhub/pull/689)
|
||||
- Add HubAuth class for authenticating with JupyterHub. This class can
|
||||
be used by any application, even outside tornado.
|
||||
- Add user groups.
|
||||
- Add `/hub/user-redirect/...` URL for redirecting users to a file on their own server.
|
||||
|
||||
|
||||
#### Changed
|
||||
|
||||
- Always install with setuptools but not eggs (effectively require
|
||||
`pip install .`) [\#722](https://github.com/jupyterhub/jupyterhub/pull/722)
|
||||
- Updated formatting of changelog. [\#711](https://github.com/jupyterhub/jupyterhub/pull/711)
|
||||
- Single-user server is provided by JupyterHub package, so single-user servers depend on JupyterHub now.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Fix docker repository location [\#719](https://github.com/jupyterhub/jupyterhub/pull/719)
|
||||
- Fix swagger spec conformance and timestamp type in API spec
|
||||
- Various redirect-loop-causing bugs have been fixed.
|
||||
|
||||
|
||||
#### Removed
|
||||
|
||||
- Deprecate `--no-ssl` command line option. It has no meaning and warns if
|
||||
used. [\#789](https://github.com/jupyterhub/jupyterhub/pull/789)
|
||||
- Deprecate `%U` username substitution in favor of `{username}`. [\#748](https://github.com/jupyterhub/jupyterhub/pull/748)
|
||||
- Removed deprecated SwarmSpawner link. [\#699](https://github.com/jupyterhub/jupyterhub/pull/699)
|
||||
|
||||
## 0.6
|
||||
|
||||
### [0.6.1] - 2016-05-04
|
||||
|
||||
Bugfixes on 0.6:
|
||||
|
||||
- statsd is an optional dependency, only needed if in use
|
||||
- Notice more quickly when servers have crashed
|
||||
- Better error pages for proxy errors
|
||||
- Add Stop All button to admin panel for stopping all servers at once
|
||||
|
||||
### [0.6.0] - 2016-04-25
|
||||
|
||||
- JupyterHub has moved to a new `jupyterhub` namespace on GitHub and Docker. What was `juptyer/jupyterhub` is now `jupyterhub/jupyterhub`, etc.
|
||||
- `jupyterhub/jupyterhub` image on DockerHub no longer loads the jupyterhub_config.py in an ONBUILD step. A new `jupyterhub/jupyterhub-onbuild` image does this
|
||||
- Add statsd support, via `c.JupyterHub.statsd_{host,port,prefix}`
|
||||
- Update to traitlets 4.1 `@default`, `@observe` APIs for traits
|
||||
- Allow disabling PAM sessions via `c.PAMAuthenticator.open_sessions = False`. This may be needed on SELinux-enabled systems, where our PAM session logic often does not work properly
|
||||
- Add `Spawner.environment` configurable, for defining extra environment variables to load for single-user servers
|
||||
- JupyterHub API tokens can be pregenerated and loaded via `JupyterHub.api_tokens`, a dict of `token: username`.
|
||||
- JupyterHub API tokens can be requested via the REST API, with a POST request to `/api/authorizations/token`.
|
||||
This can only be used if the Authenticator has a username and password.
|
||||
- Various fixes for user URLs and redirects
|
||||
|
||||
|
||||
## [0.5] - 2016-03-07
|
||||
|
||||
|
||||
- Single-user server must be run with Jupyter Notebook ≥ 4.0
|
||||
- Require `--no-ssl` confirmation to allow the Hub to be run without SSL (e.g. behind SSL termination in nginx)
|
||||
- Add lengths to text fields for MySQL support
|
||||
- Add `Spawner.disable_user_config` for preventing user-owned configuration from modifying single-user servers.
|
||||
- Fixes for MySQL support.
|
||||
- Add ability to run each user's server on its own subdomain. Requires wildcard DNS and wildcard SSL to be feasible. Enable subdomains by setting `JupyterHub.subdomain_host = 'https://jupyterhub.domain.tld[:port]'`.
|
||||
- Use `127.0.0.1` for local communication instead of `localhost`, avoiding issues with DNS on some systems.
|
||||
- Fix race that could add users to proxy prematurely if spawning is slow.
|
||||
|
||||
## 0.4
|
||||
|
||||
### 0.4.1
|
||||
### [0.4.1] - 2016-02-03
|
||||
|
||||
Fix removal of `/login` page in 0.4.0, breaking some OAuth providers.
|
||||
|
||||
### 0.4.0
|
||||
### [0.4.0] - 2016-02-01
|
||||
|
||||
- Add `Spawner.user_options_form` for specifying an HTML form to present to users,
|
||||
allowing users to influence the spawning of their own servers.
|
||||
@@ -19,7 +113,7 @@ Fix removal of `/login` page in 0.4.0, breaking some OAuth providers.
|
||||
- 0.4 will be the last JupyterHub release where single-user servers running IPython 3 is supported instead of Notebook ≥ 4.0.
|
||||
|
||||
|
||||
## 0.3
|
||||
## [0.3] - 2015-11-04
|
||||
|
||||
- No longer make the user starting the Hub an admin
|
||||
- start PAM sessions on login
|
||||
@@ -27,13 +121,24 @@ Fix removal of `/login` page in 0.4.0, breaking some OAuth providers.
|
||||
allowing deeper interaction between Spawner/Authenticator pairs.
|
||||
- login redirect fixes
|
||||
|
||||
## 0.2
|
||||
## [0.2] - 2015-07-12
|
||||
|
||||
- Based on standalone traitlets instead of IPython.utils.traitlets
|
||||
- multiple users in admin panel
|
||||
- Fixes for usernames that require escaping
|
||||
|
||||
## 0.1
|
||||
## 0.1 - 2015-03-07
|
||||
|
||||
First preview release
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/jupyterhub/jupyterhub/compare/0.7.1...HEAD
|
||||
[0.7.1]: https://github.com/jupyterhub/jupyterhub/compare/0.7.0...0.7.1
|
||||
[0.7.0]: https://github.com/jupyterhub/jupyterhub/compare/0.6.1...0.7.0
|
||||
[0.6.1]: https://github.com/jupyterhub/jupyterhub/compare/0.6.0...0.6.1
|
||||
[0.6.0]: https://github.com/jupyterhub/jupyterhub/compare/0.5.0...0.6.0
|
||||
[0.5]: https://github.com/jupyterhub/jupyterhub/compare/0.4.1...0.5.0
|
||||
[0.4.1]: https://github.com/jupyterhub/jupyterhub/compare/0.4.0...0.4.1
|
||||
[0.4.0]: https://github.com/jupyterhub/jupyterhub/compare/0.3.0...0.4.0
|
||||
[0.3]: https://github.com/jupyterhub/jupyterhub/compare/0.2.0...0.3.0
|
||||
[0.2]: https://github.com/jupyterhub/jupyterhub/compare/0.1.0...0.2.0
|
||||
|
@@ -1,59 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# JupyterHub documentation build configuration file, created by
|
||||
# sphinx-quickstart on Mon Jan 4 16:31:09 2016.
|
||||
#
|
||||
# This file is execfile()d with the current directory set to its
|
||||
# containing dir.
|
||||
#
|
||||
# Note that not all possible configuration values are present in this
|
||||
# autogenerated file.
|
||||
#
|
||||
# All configuration values have a default; values that are commented out
|
||||
# serve to show the default.
|
||||
|
||||
import sys
|
||||
import os
|
||||
import shlex
|
||||
|
||||
# Needed for conversion from markdown to html
|
||||
# For conversion from markdown to html
|
||||
import recommonmark.parser
|
||||
|
||||
# If extensions (or modules to document with autodoc) are in another directory,
|
||||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
# Set paths
|
||||
#sys.path.insert(0, os.path.abspath('.'))
|
||||
|
||||
# -- General configuration ------------------------------------------------
|
||||
|
||||
# If your documentation needs a minimal Sphinx version, state it here.
|
||||
needs_sphinx = '1.3'
|
||||
# Minimal Sphinx version
|
||||
needs_sphinx = '1.4'
|
||||
|
||||
# Add any Sphinx extension module names here, as strings. They can be
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
# Sphinx extension modules
|
||||
extensions = [
|
||||
'sphinx.ext.autodoc',
|
||||
'sphinx.ext.intersphinx',
|
||||
'sphinx.ext.napoleon',
|
||||
]
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ['_templates']
|
||||
|
||||
# Jupyter uses recommonmark's parser to convert markdown
|
||||
source_parsers = {
|
||||
'.md': 'recommonmark.parser.CommonMarkParser',
|
||||
}
|
||||
|
||||
# The suffix(es) of source filenames.
|
||||
# You can specify multiple suffix as a list of string:
|
||||
# source_suffix = ['.rst', '.md']
|
||||
source_suffix = ['.rst', '.md']
|
||||
|
||||
# The encoding of source files.
|
||||
#source_encoding = 'utf-8-sig'
|
||||
|
||||
# The master toctree document.
|
||||
master_doc = 'index'
|
||||
|
||||
@@ -62,12 +32,10 @@ project = u'JupyterHub'
|
||||
copyright = u'2016, Project Jupyter team'
|
||||
author = u'Project Jupyter team'
|
||||
|
||||
# The version info for the project you're documenting, acts as replacement for
|
||||
# |version| and |release|, also used in various other places throughout the
|
||||
# built documents.
|
||||
# Project Jupyter uses the following to autopopulate version
|
||||
# Autopopulate version
|
||||
from os.path import dirname
|
||||
root = dirname(dirname(dirname(__file__)))
|
||||
docs = dirname(dirname(__file__))
|
||||
root = dirname(docs)
|
||||
sys.path.insert(0, root)
|
||||
|
||||
import jupyterhub
|
||||
@@ -76,162 +44,59 @@ version = '%i.%i' % jupyterhub.version_info[:2]
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = jupyterhub.__version__
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
#
|
||||
# This is also used if you do content translation via gettext catalogs.
|
||||
# Usually you set "language" from the command line for these cases.
|
||||
language = None
|
||||
|
||||
# There are two options for replacing |today|: either, you set today to some
|
||||
# non-false value, then it is used:
|
||||
#today = ''
|
||||
# Else, today_fmt is used as the format for a strftime call.
|
||||
#today_fmt = '%B %d, %Y'
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
exclude_patterns = []
|
||||
|
||||
# The reST default role (used for this markup: `text`) to use for all
|
||||
# documents.
|
||||
#default_role = None
|
||||
|
||||
# If true, '()' will be appended to :func: etc. cross-reference text.
|
||||
#add_function_parentheses = True
|
||||
|
||||
# If true, the current module name will be prepended to all description
|
||||
# unit titles (such as .. function::).
|
||||
#add_module_names = True
|
||||
|
||||
# If true, sectionauthor and moduleauthor directives will be shown in the
|
||||
# output. They are ignored by default.
|
||||
#show_authors = False
|
||||
|
||||
# The name of the Pygments (syntax highlighting) style to use.
|
||||
pygments_style = 'sphinx'
|
||||
|
||||
# A list of ignored prefixes for module index sorting.
|
||||
#modindex_common_prefix = []
|
||||
|
||||
# If true, keep warnings as "system message" paragraphs in the built documents.
|
||||
#keep_warnings = False
|
||||
|
||||
# If true, `todo` and `todoList` produce output, else they produce nothing.
|
||||
todo_include_todos = False
|
||||
|
||||
# -- Source -------------------------------------------------------------
|
||||
|
||||
source_parsers = {
|
||||
'.md': 'recommonmark.parser.CommonMarkParser',
|
||||
}
|
||||
|
||||
source_suffix = ['.rst', '.md']
|
||||
#source_encoding = 'utf-8-sig'
|
||||
|
||||
# -- Options for HTML output ----------------------------------------------
|
||||
|
||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
# The theme to use for HTML and HTML Help pages.
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
|
||||
# Theme options are theme-specific and customize the look and feel of a theme
|
||||
# further. For a list of options available for each theme, see the
|
||||
# documentation.
|
||||
#html_theme_options = {}
|
||||
|
||||
# Add any paths that contain custom themes here, relative to this directory.
|
||||
#html_theme_path = []
|
||||
|
||||
# The name for this set of Sphinx documents. If None, it defaults to
|
||||
# "<project> v<release> documentation".
|
||||
#html_title = None
|
||||
|
||||
# A shorter title for the navigation bar. Default is the same as html_title.
|
||||
#html_short_title = None
|
||||
|
||||
# The name of an image file (relative to this directory) to place at the top
|
||||
# of the sidebar.
|
||||
#html_logo = None
|
||||
|
||||
# The name of an image file (within the static path) to use as favicon of the
|
||||
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
|
||||
# pixels large.
|
||||
#html_favicon = None
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
# Paths that contain custom static files (such as style sheets)
|
||||
html_static_path = ['_static']
|
||||
|
||||
# Add any extra paths that contain custom files (such as robots.txt or
|
||||
# .htaccess) here, relative to this directory. These files are copied
|
||||
# directly to the root of the documentation.
|
||||
#html_extra_path = []
|
||||
|
||||
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
|
||||
# using the given strftime format.
|
||||
#html_last_updated_fmt = '%b %d, %Y'
|
||||
|
||||
# If true, SmartyPants will be used to convert quotes and dashes to
|
||||
# typographically correct entities.
|
||||
#html_use_smartypants = True
|
||||
|
||||
# Custom sidebar templates, maps document names to template names.
|
||||
#html_sidebars = {}
|
||||
|
||||
# Additional templates that should be rendered to pages, maps page names to
|
||||
# template names.
|
||||
#html_additional_pages = {}
|
||||
|
||||
# If false, no module index is generated.
|
||||
#html_domain_indices = True
|
||||
|
||||
# If false, no index is generated.
|
||||
#html_use_index = True
|
||||
|
||||
# If true, the index is split into individual pages for each letter.
|
||||
#html_split_index = False
|
||||
|
||||
# If true, links to the reST sources are added to the pages.
|
||||
#html_show_sourcelink = True
|
||||
|
||||
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
|
||||
#html_show_sphinx = True
|
||||
|
||||
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
|
||||
#html_show_copyright = True
|
||||
|
||||
# If true, an OpenSearch description file will be output, and all pages will
|
||||
# contain a <link> tag referring to it. The value of this option must be the
|
||||
# base URL from which the finished HTML is served.
|
||||
#html_use_opensearch = ''
|
||||
|
||||
# This is the file name suffix for HTML files (e.g. ".xhtml").
|
||||
#html_file_suffix = None
|
||||
|
||||
# Language to be used for generating the HTML full-text search index.
|
||||
# Sphinx supports the following languages:
|
||||
# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja'
|
||||
# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr'
|
||||
#html_search_language = 'en'
|
||||
|
||||
# A dictionary with options for the search language support, empty by default.
|
||||
# Now only 'ja' uses this config value
|
||||
#html_search_options = {'type': 'default'}
|
||||
|
||||
# The name of a javascript file (relative to the configuration directory) that
|
||||
# implements a search results scorer. If empty, the default will be used.
|
||||
#html_search_scorer = 'scorer.js'
|
||||
|
||||
# Output file base name for HTML help builder.
|
||||
htmlhelp_basename = 'JupyterHubdoc'
|
||||
|
||||
# -- Options for LaTeX output ---------------------------------------------
|
||||
|
||||
latex_elements = {
|
||||
# The paper size ('letterpaper' or 'a4paper').
|
||||
#'papersize': 'letterpaper',
|
||||
|
||||
# The font size ('10pt', '11pt' or '12pt').
|
||||
#'pointsize': '10pt',
|
||||
|
||||
# Additional stuff for the LaTeX preamble.
|
||||
#'preamble': '',
|
||||
|
||||
# Latex figure (float) alignment
|
||||
#'figure_align': 'htbp',
|
||||
}
|
||||
|
||||
@@ -243,28 +108,15 @@ latex_documents = [
|
||||
u'Project Jupyter team', 'manual'),
|
||||
]
|
||||
|
||||
# The name of an image file (relative to this directory) to place at the top of
|
||||
# the title page.
|
||||
#latex_logo = None
|
||||
|
||||
# For "manual" documents, if this is true, then toplevel headings are parts,
|
||||
# not chapters.
|
||||
#latex_use_parts = False
|
||||
|
||||
# If true, show page references after internal links.
|
||||
#latex_show_pagerefs = False
|
||||
|
||||
# If true, show URL addresses after external links.
|
||||
#latex_show_urls = False
|
||||
|
||||
# Documents to append as an appendix to all manuals.
|
||||
#latex_appendices = []
|
||||
|
||||
# If false, no module index is generated.
|
||||
#latex_domain_indices = True
|
||||
|
||||
|
||||
# -- Options for manual page output ---------------------------------------
|
||||
# -- manual page output -------------------------------------------------
|
||||
|
||||
# One entry per manual page. List of tuples
|
||||
# (source start file, name, description, authors, manual section).
|
||||
@@ -273,11 +125,10 @@ man_pages = [
|
||||
[author], 1)
|
||||
]
|
||||
|
||||
# If true, show URL addresses after external links.
|
||||
#man_show_urls = False
|
||||
|
||||
|
||||
# -- Options for Texinfo output -------------------------------------------
|
||||
# -- Texinfo output -----------------------------------------------------
|
||||
|
||||
# Grouping the document tree into Texinfo files. List of tuples
|
||||
# (source start file, target name, title, author,
|
||||
@@ -288,20 +139,13 @@ texinfo_documents = [
|
||||
'Miscellaneous'),
|
||||
]
|
||||
|
||||
# Documents to append as an appendix to all manuals.
|
||||
#texinfo_appendices = []
|
||||
|
||||
# If false, no module index is generated.
|
||||
#texinfo_domain_indices = True
|
||||
|
||||
# How to display URL addresses: 'footnote', 'no', or 'inline'.
|
||||
#texinfo_show_urls = 'footnote'
|
||||
|
||||
# If true, do not generate a @detailmenu in the "Top" node's menu.
|
||||
#texinfo_no_detailmenu = False
|
||||
|
||||
|
||||
# -- Options for Epub output ----------------------------------------------
|
||||
# -- Epub output --------------------------------------------------------
|
||||
|
||||
# Bibliographic Dublin Core info.
|
||||
epub_title = project
|
||||
@@ -309,78 +153,35 @@ epub_author = author
|
||||
epub_publisher = author
|
||||
epub_copyright = copyright
|
||||
|
||||
# The basename for the epub file. It defaults to the project name.
|
||||
#epub_basename = project
|
||||
|
||||
# The HTML theme for the epub output. Since the default themes are not optimized
|
||||
# for small screen space, using the same theme for HTML and epub output is
|
||||
# usually not wise. This defaults to 'epub', a theme designed to save visual
|
||||
# space.
|
||||
#epub_theme = 'epub'
|
||||
|
||||
# The language of the text. It defaults to the language option
|
||||
# or 'en' if the language is not set.
|
||||
#epub_language = ''
|
||||
|
||||
# The scheme of the identifier. Typical schemes are ISBN or URL.
|
||||
#epub_scheme = ''
|
||||
|
||||
# The unique identifier of the text. This can be a ISBN number
|
||||
# or the project homepage.
|
||||
#epub_identifier = ''
|
||||
|
||||
# A unique identification for the text.
|
||||
#epub_uid = ''
|
||||
|
||||
# A tuple containing the cover image and cover page html template filenames.
|
||||
#epub_cover = ()
|
||||
|
||||
# A sequence of (type, uri, title) tuples for the guide element of content.opf.
|
||||
#epub_guide = ()
|
||||
|
||||
# HTML files that should be inserted before the pages created by sphinx.
|
||||
# The format is a list of tuples containing the path and title.
|
||||
#epub_pre_files = []
|
||||
|
||||
# HTML files shat should be inserted after the pages created by sphinx.
|
||||
# The format is a list of tuples containing the path and title.
|
||||
#epub_post_files = []
|
||||
|
||||
# A list of files that should not be packed into the epub file.
|
||||
epub_exclude_files = ['search.html']
|
||||
|
||||
# The depth of the table of contents in toc.ncx.
|
||||
#epub_tocdepth = 3
|
||||
# -- Intersphinx ----------------------------------------------------------
|
||||
|
||||
# Allow duplicate toc entries.
|
||||
#epub_tocdup = True
|
||||
|
||||
# Choose between 'default' and 'includehidden'.
|
||||
#epub_tocscope = 'default'
|
||||
|
||||
# Fix unsupported image types using the Pillow.
|
||||
#epub_fix_images = False
|
||||
|
||||
# Scale large images.
|
||||
#epub_max_image_width = 0
|
||||
|
||||
# How to display URL addresses: 'footnote', 'no', or 'inline'.
|
||||
#epub_show_urls = 'inline'
|
||||
|
||||
# If false, no index is generated.
|
||||
#epub_use_index = True
|
||||
|
||||
|
||||
# Example configuration for intersphinx: refer to the Python standard library.
|
||||
intersphinx_mapping = {'https://docs.python.org/': None}
|
||||
|
||||
# Read The Docs
|
||||
# on_rtd is whether we are on readthedocs.org, this line of code grabbed from docs.readthedocs.org
|
||||
# -- Read The Docs --------------------------------------------------------
|
||||
|
||||
on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
|
||||
|
||||
if not on_rtd: # only import and set the theme if we're building docs locally
|
||||
if not on_rtd:
|
||||
# only import and set the theme if we're building docs locally
|
||||
import sphinx_rtd_theme
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
||||
else:
|
||||
# readthedocs.org uses their theme by default, so no need to specify it
|
||||
# build rest-api, since RTD doesn't run make
|
||||
from subprocess import check_call as sh
|
||||
sh(['make', 'rest-api'], cwd=docs)
|
||||
|
||||
# otherwise, readthedocs.org uses their theme by default, so no need to specify it
|
||||
# -- Spell checking -------------------------------------------------------
|
||||
|
||||
try:
|
||||
import sphinxcontrib.spelling
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
extensions.append("sphinxcontrib.spelling")
|
||||
|
||||
spelling_word_list_filename='spelling_wordlist.txt'
|
||||
|
194
docs/source/config-examples.md
Normal file
194
docs/source/config-examples.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# Configuration examples
|
||||
|
||||
This section provides configuration files and tips for the following
|
||||
configurations:
|
||||
|
||||
- Example with GitHub OAuth
|
||||
- Example with nginx reverse proxy
|
||||
|
||||
|
||||
## Example with GitHub OAuth
|
||||
|
||||
In the following example, we show a configuration files for a fairly standard JupyterHub deployment with the following assumptions:
|
||||
|
||||
* JupyterHub is running on a single cloud server
|
||||
* Using SSL on the standard HTTPS port 443
|
||||
* You want to use GitHub OAuth (using oauthenticator) for login
|
||||
* You need the users to exist locally on the server
|
||||
* You want users' notebooks to be served from `~/assignments` to allow users to browse for notebooks within
|
||||
other users home directories
|
||||
* You want the landing page for each user to be a Welcome.ipynb notebook in their assignments directory.
|
||||
* All runtime files are put into `/srv/jupyterhub` and log files in `/var/log`.
|
||||
|
||||
Let's start out with `jupyterhub_config.py`:
|
||||
|
||||
```python
|
||||
# jupyterhub_config.py
|
||||
c = get_config()
|
||||
|
||||
import os
|
||||
pjoin = os.path.join
|
||||
|
||||
runtime_dir = os.path.join('/srv/jupyterhub')
|
||||
ssl_dir = pjoin(runtime_dir, 'ssl')
|
||||
if not os.path.exists(ssl_dir):
|
||||
os.makedirs(ssl_dir)
|
||||
|
||||
|
||||
# https on :443
|
||||
c.JupyterHub.port = 443
|
||||
c.JupyterHub.ssl_key = pjoin(ssl_dir, 'ssl.key')
|
||||
c.JupyterHub.ssl_cert = pjoin(ssl_dir, 'ssl.cert')
|
||||
|
||||
# put the JupyterHub cookie secret and state db
|
||||
# in /var/run/jupyterhub
|
||||
c.JupyterHub.cookie_secret_file = pjoin(runtime_dir, 'cookie_secret')
|
||||
c.JupyterHub.db_url = pjoin(runtime_dir, 'jupyterhub.sqlite')
|
||||
# or `--db=/path/to/jupyterhub.sqlite` on the command-line
|
||||
|
||||
# put the log file in /var/log
|
||||
c.JupyterHub.extra_log_file = '/var/log/jupyterhub.log'
|
||||
|
||||
# use GitHub OAuthenticator for local users
|
||||
|
||||
c.JupyterHub.authenticator_class = 'oauthenticator.LocalGitHubOAuthenticator'
|
||||
c.GitHubOAuthenticator.oauth_callback_url = os.environ['OAUTH_CALLBACK_URL']
|
||||
# create system users that don't exist yet
|
||||
c.LocalAuthenticator.create_system_users = True
|
||||
|
||||
# specify users and admin
|
||||
c.Authenticator.whitelist = {'rgbkrk', 'minrk', 'jhamrick'}
|
||||
c.Authenticator.admin_users = {'jhamrick', 'rgbkrk'}
|
||||
|
||||
# start single-user notebook servers in ~/assignments,
|
||||
# with ~/assignments/Welcome.ipynb as the default landing page
|
||||
# this config could also be put in
|
||||
# /etc/ipython/ipython_notebook_config.py
|
||||
c.Spawner.notebook_dir = '~/assignments'
|
||||
c.Spawner.args = ['--NotebookApp.default_url=/notebooks/Welcome.ipynb']
|
||||
```
|
||||
|
||||
Using the GitHub Authenticator [requires a few additional env variables][oauth-setup],
|
||||
which we will need to set when we launch the server:
|
||||
|
||||
```bash
|
||||
export GITHUB_CLIENT_ID=github_id
|
||||
export GITHUB_CLIENT_SECRET=github_secret
|
||||
export OAUTH_CALLBACK_URL=https://example.com/hub/oauth_callback
|
||||
export CONFIGPROXY_AUTH_TOKEN=super-secret
|
||||
jupyterhub -f /path/to/aboveconfig.py
|
||||
```
|
||||
|
||||
## Example with nginx reverse proxy
|
||||
|
||||
In the following example, we show configuration files for a JupyterHub server running locally on port `8000` but accessible from the outside on the standard SSL port `443`. This could be useful if the JupyterHub server machine is also hosting other domains or content on `443`. The goal here is to have the following be true:
|
||||
|
||||
* JupyterHub is running on a server, accessed *only* via `HUB.DOMAIN.TLD:443`
|
||||
* On the same machine, `NO_HUB.DOMAIN.TLD` strictly serves different content, also on port `443`
|
||||
* `nginx` is used to manage the web servers / reverse proxy (which means that only nginx will be able to bind two servers to `443`)
|
||||
* After testing, the server in question should be able to score an A+ on the Qualys SSL Labs [SSL Server Test](https://www.ssllabs.com/ssltest/)
|
||||
|
||||
Let's start out with `jupyterhub_config.py`:
|
||||
|
||||
```python
|
||||
# Force the proxy to only listen to connections to 127.0.0.1
|
||||
c.JupyterHub.ip = '127.0.0.1'
|
||||
```
|
||||
|
||||
The `nginx` server config files are fairly standard fare except for the two `location` blocks within the `HUB.DOMAIN.TLD` config file:
|
||||
|
||||
```bash
|
||||
# HTTP server to redirect all 80 traffic to SSL/HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name HUB.DOMAIN.TLD;
|
||||
|
||||
# Tell all requests to port 80 to be 302 redirected to HTTPS
|
||||
return 302 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# HTTPS server to handle JupyterHub
|
||||
server {
|
||||
listen 443;
|
||||
ssl on;
|
||||
|
||||
server_name HUB.DOMAIN.TLD;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem
|
||||
ssl_certificate_key /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem
|
||||
|
||||
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_dhparam /etc/ssl/certs/dhparam.pem;
|
||||
ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:CAMELLIA:DES-CBC3-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA';
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:50m;
|
||||
ssl_stapling on;
|
||||
ssl_stapling_verify on;
|
||||
add_header Strict-Transport-Security max-age=15768000;
|
||||
|
||||
# Managing literal requests to the JupyterHub front end
|
||||
location / {
|
||||
proxy_pass https://127.0.0.1:8000;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# Managing WebHook/Socket requests between hub user servers and external proxy
|
||||
location ~* /(api/kernels/[^/]+/(channels|iopub|shell|stdin)|terminals/websocket)/? {
|
||||
proxy_pass https://127.0.0.1:8000;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# WebSocket support
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
}
|
||||
|
||||
# Managing requests to verify letsencrypt host
|
||||
location ~ /.well-known {
|
||||
allow all;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
`nginx` will now be the front facing element of JupyterHub on `443` which means it is also free to bind other servers, like `NO_HUB.DOMAIN.TLD` to the same port on the same machine and network interface. In fact, one can simply use the same server blocks as above for `NO_HUB` and simply add line for the root directory of the site as well as the applicable location call:
|
||||
|
||||
```bash
|
||||
server {
|
||||
listen 80;
|
||||
server_name NO_HUB.DOMAIN.TLD;
|
||||
|
||||
# Tell all requests to port 80 to be 302 redirected to HTTPS
|
||||
return 302 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443;
|
||||
ssl on;
|
||||
|
||||
# INSERT OTHER SSL PARAMETERS HERE AS ABOVE
|
||||
|
||||
# Set the appropriate root directory
|
||||
root /var/www/html
|
||||
|
||||
# Set URI handling
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
# Managing requests to verify letsencrypt host
|
||||
location ~ /.well-known {
|
||||
allow all;
|
||||
}
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
Now just restart `nginx`, restart the JupyterHub, and enjoy accessing https://HUB.DOMAIN.TLD while serving other content securely on https://NO_HUB.DOMAIN.TLD.
|
58
docs/source/contributor-list.md
Normal file
58
docs/source/contributor-list.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Contributors
|
||||
|
||||
Project Jupyter thanks the following people for their help and
|
||||
contribution on JupyterHub:
|
||||
|
||||
- anderbubble
|
||||
- betatim
|
||||
- Carreau
|
||||
- ckald
|
||||
- cwaldbieser
|
||||
- danielballen
|
||||
- daradib
|
||||
- datapolitan
|
||||
- dblockow-d2dcrc
|
||||
- dietmarw
|
||||
- DominicFollettSmith
|
||||
- dsblank
|
||||
- ellisonbg
|
||||
- evanlinde
|
||||
- Fokko
|
||||
- iamed18
|
||||
- JamiesHQ
|
||||
- jdavidheiser
|
||||
- jhamrick
|
||||
- josephtate
|
||||
- kinuax
|
||||
- KrishnaPG
|
||||
- ksolan
|
||||
- mbmilligan
|
||||
- minrk
|
||||
- mistercrunch
|
||||
- Mistobaan
|
||||
- mwmarkland
|
||||
- nthiery
|
||||
- ObiWahn
|
||||
- ozancaglayan
|
||||
- parente
|
||||
- PeterDaveHello
|
||||
- peterruppel
|
||||
- rafael-ladislau
|
||||
- rgbkrk
|
||||
- robnagler
|
||||
- ryanlovett
|
||||
- Scrypy
|
||||
- shreddd
|
||||
- spoorthyv
|
||||
- ssanderson
|
||||
- takluyver
|
||||
- temogen
|
||||
- TimShawver
|
||||
- Todd-Z-Li
|
||||
- toobaz
|
||||
- tsaeger
|
||||
- vilhelmen
|
||||
- willingc
|
||||
- YannBrrd
|
||||
- yuvipanda
|
||||
- zoltan-fedor
|
@@ -1,31 +1,89 @@
|
||||
# Getting started with JupyterHub
|
||||
|
||||
This document describes some of the basics of configuring JupyterHub to do what you want.
|
||||
JupyterHub is highly customizable, so there's a lot to cover.
|
||||
This section contains getting started information on the following topics:
|
||||
|
||||
- [Technical Overview](getting-started.html#technical-overview)
|
||||
- [Installation](getting-started.html#installation)
|
||||
- [Configuration](getting-started.html#configuration)
|
||||
- [Networking](getting-started.html#networking)
|
||||
- [Security](getting-started.html#security)
|
||||
- [Authentication and users](getting-started.html#authentication-and-users)
|
||||
- [Spawners and single-user notebook servers](getting-started.html#spawners-and-single-user-notebook-servers)
|
||||
- [External Services](getting-started.html#external-services)
|
||||
|
||||
|
||||
## Installation
|
||||
## Technical Overview
|
||||
|
||||
See [the readme](https://github.com/jupyter/jupyterhub/blob/master/README.md) for help installing JupyterHub.
|
||||
JupyterHub is a set of processes that together provide a single user Jupyter
|
||||
Notebook server for each person in a group.
|
||||
|
||||
### Three subsystems
|
||||
Three major subsystems run by the `jupyterhub` command line program:
|
||||
|
||||
- **Single-User Notebook Server**: a dedicated, single-user, Jupyter Notebook server is
|
||||
started for each user on the system when the user logs in. The object that
|
||||
starts these servers is called a **Spawner**.
|
||||
- **Proxy**: the public facing part of JupyterHub that uses a dynamic proxy
|
||||
to route HTTP requests to the Hub and Single User Notebook Servers.
|
||||
- **Hub**: manages user accounts, authentication, and coordinates Single User
|
||||
Notebook Servers using a Spawner.
|
||||
|
||||

|
||||
|
||||
|
||||
## Overview
|
||||
### Deployment server
|
||||
To use JupyterHub, you need a Unix server (typically Linux) running somewhere
|
||||
that is accessible to your team on the network. The JupyterHub server can be
|
||||
on an internal network at your organization, or it can run on the public
|
||||
internet (in which case, take care with the Hub's
|
||||
[security](getting-started.html#security)).
|
||||
|
||||
JupyterHub is a set of processes that together provide a multiuser Jupyter Notebook server.
|
||||
There are three main categories of processes run by the `jupyterhub` command line program:
|
||||
### Basic operation
|
||||
Users access JupyterHub through a web browser, by going to the IP address or
|
||||
the domain name of the server.
|
||||
|
||||
- **Single User Server**: a dedicated, single-user, Jupyter Notebook is started for each user on the system
|
||||
when they log in. The object that starts these processes is called a Spawner.
|
||||
- **Proxy**: the public facing part of the server that uses a dynamic proxy to route HTTP requests
|
||||
to the Hub and Single User Servers.
|
||||
- **Hub**: manages user accounts and authentication and coordinates Single Users Servers using a Spawner.
|
||||
Basic principles of operation:
|
||||
|
||||
## JupyterHub's default behavior
|
||||
* Hub spawns proxy
|
||||
* Proxy forwards all requests to hub by default
|
||||
* Hub handles login, and spawns single-user servers on demand
|
||||
* Hub configures proxy to forward url prefixes to single-user servers
|
||||
|
||||
Different **[authenticators](authenticators.html)** control access
|
||||
to JupyterHub. The default one (PAM) uses the user accounts on the server where
|
||||
JupyterHub is running. If you use this, you will need to create a user account
|
||||
on the system for each user on your team. Using other authenticators, you can
|
||||
allow users to sign in with e.g. a GitHub account, or with any single-sign-on
|
||||
system your organization has.
|
||||
|
||||
Next, **[spawners](spawners.html)** control how JupyterHub starts
|
||||
the individual notebook server for each user. The default spawner will
|
||||
start a notebook server on the same machine running under their system username.
|
||||
The other main option is to start each server in a separate container, often
|
||||
using Docker.
|
||||
|
||||
### Default behavior
|
||||
|
||||
**IMPORTANT: You should not run JupyterHub without SSL encryption on a public network.**
|
||||
|
||||
See [Security documentation](#security) for how to configure JupyterHub to use SSL,
|
||||
or put it behind SSL termination in another proxy server, such as nginx.
|
||||
|
||||
---
|
||||
|
||||
**Deprecation note:** Removed `--no-ssl` in version 0.7.
|
||||
|
||||
JupyterHub versions 0.5 and 0.6 require extra confirmation via `--no-ssl` to
|
||||
allow running without SSL using the command `jupyterhub --no-ssl`. The
|
||||
`--no-ssl` command line option is not needed anymore in version 0.7.
|
||||
|
||||
---
|
||||
|
||||
To start JupyterHub in its default configuration, type the following at the command line:
|
||||
|
||||
```bash
|
||||
sudo jupyterhub
|
||||
```
|
||||
|
||||
The default Authenticator that ships with JupyterHub authenticates users
|
||||
with their system name and password (via [PAM][]).
|
||||
@@ -37,62 +95,99 @@ These servers listen on localhost, and start in the given user's home directory.
|
||||
By default, the **Proxy** listens on all public interfaces on port 8000.
|
||||
Thus you can reach JupyterHub through either:
|
||||
|
||||
http://localhost:8000
|
||||
|
||||
or any other public IP or domain pointing to your system.
|
||||
- `http://localhost:8000`
|
||||
- or any other public IP or domain pointing to your system.
|
||||
|
||||
In their default configuration, the other services, the **Hub** and **Single-User Servers**,
|
||||
all communicate with each other on localhost only.
|
||||
|
||||
**NOTE:** In its default configuration, JupyterHub runs without SSL encryption (HTTPS).
|
||||
You should not run JupyterHub without SSL encryption on a public network.
|
||||
See [Security documentation](#Security) for how to configure JupyterHub to use SSL.
|
||||
|
||||
By default, starting JupyterHub will write two files to disk in the current working directory:
|
||||
|
||||
- `jupyterhub.sqlite` is the sqlite database containing all of the state of the **Hub**.
|
||||
This file allows the **Hub** to remember what users are running and where,
|
||||
as well as other information enabling you to restart parts of JupyterHub separately.
|
||||
as well as other information enabling you to restart parts of JupyterHub separately. It is
|
||||
important to note that this database contains *no* sensitive information other than **Hub**
|
||||
usernames.
|
||||
- `jupyterhub_cookie_secret` is the encryption key used for securing cookies.
|
||||
This file needs to persist in order for restarting the Hub server to avoid invalidating cookies.
|
||||
Conversely, deleting this file and restarting the server effectively invalidates all login cookies.
|
||||
The cookie secret file is discussed in the [Cookie Secret documentation](#Cookie secret).
|
||||
The cookie secret file is discussed in the [Cookie Secret documentation](#cookie-secret).
|
||||
|
||||
The location of these files can be specified via configuration, discussed below.
|
||||
|
||||
## Installation
|
||||
|
||||
## How to configure JupyterHub
|
||||
See the project's [README](https://github.com/jupyterhub/jupyterhub/blob/master/README.md)
|
||||
for help installing JupyterHub.
|
||||
|
||||
### Planning your installation
|
||||
|
||||
Prior to beginning installation, it's helpful to consider some of the following:
|
||||
- deployment system (bare metal, Docker)
|
||||
- Authentication (PAM, OAuth, etc.)
|
||||
- Spawner of singleuser notebook servers (Docker, Batch, etc.)
|
||||
- Services (nbgrader, etc.)
|
||||
- JupyterHub database (default SQLite; traditional RDBMS such as PostgreSQL,)
|
||||
MySQL, or other databases supported by [SQLAlchemy](http://www.sqlalchemy.org))
|
||||
|
||||
### Folders and File Locations
|
||||
|
||||
It is recommended to put all of the files used by JupyterHub into standard
|
||||
UNIX filesystem locations.
|
||||
|
||||
* `/srv/jupyterhub` for all security and runtime files
|
||||
* `/etc/jupyterhub` for all configuration files
|
||||
* `/var/log` for log files
|
||||
|
||||
## Configuration
|
||||
|
||||
JupyterHub is configured in two ways:
|
||||
|
||||
1. Command-line arguments
|
||||
2. Configuration files
|
||||
1. Configuration file
|
||||
2. Command-line arguments
|
||||
|
||||
Type the following for brief information about the command line arguments:
|
||||
|
||||
jupyterhub -h
|
||||
|
||||
or:
|
||||
|
||||
jupyterhub --help-all
|
||||
|
||||
for the full command line help.
|
||||
|
||||
By default, JupyterHub will look for a configuration file (can be missing)
|
||||
### Configuration file
|
||||
By default, JupyterHub will look for a configuration file (which may not be created yet)
|
||||
named `jupyterhub_config.py` in the current working directory.
|
||||
You can create an empty configuration file with
|
||||
You can create an empty configuration file with:
|
||||
|
||||
|
||||
jupyterhub --generate-config
|
||||
```bash
|
||||
jupyterhub --generate-config
|
||||
```
|
||||
|
||||
This empty configuration file has descriptions of all configuration variables and their default
|
||||
values. You can load a specific config file with:
|
||||
|
||||
jupyterhub -f /path/to/jupyterhub_config.py
|
||||
```bash
|
||||
jupyterhub -f /path/to/jupyterhub_config.py
|
||||
```
|
||||
|
||||
See also: [general docs](http://ipython.org/ipython-doc/dev/development/config.html)
|
||||
on the config system Jupyter uses.
|
||||
|
||||
### Command-line arguments
|
||||
Type the following for brief information about the command-line arguments:
|
||||
|
||||
```bash
|
||||
jupyterhub -h
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```bash
|
||||
jupyterhub --help-all
|
||||
```
|
||||
|
||||
for the full command line help.
|
||||
|
||||
All configurable options are technically configurable on the command-line,
|
||||
even if some are really inconvenient to type. Just replace the desired option,
|
||||
`c.Class.trait`, with `--Class.trait`. For example, to configure the
|
||||
`c.Spawner.notebook_dir` trait from the command-line:
|
||||
|
||||
```bash
|
||||
jupyterhub --Spawner.notebook_dir='~/assignments'
|
||||
```
|
||||
|
||||
## Networking
|
||||
|
||||
@@ -105,7 +200,9 @@ instead, use of `'0.0.0.0'` is preferred.
|
||||
Changing the IP address and port can be done with the following command line
|
||||
arguments:
|
||||
|
||||
jupyterhub --ip=192.168.1.2 --port=443
|
||||
```bash
|
||||
jupyterhub --ip=192.168.1.2 --port=443
|
||||
```
|
||||
|
||||
Or by placing the following lines in a configuration file:
|
||||
|
||||
@@ -120,6 +217,7 @@ Configuring only the main IP and port of JupyterHub should be sufficient for mos
|
||||
However, more customized scenarios may need additional networking details to
|
||||
be configured.
|
||||
|
||||
|
||||
### Configuring the Proxy's REST API communication IP address and port (optional)
|
||||
The Hub service talks to the proxy via a REST API on a secondary port,
|
||||
whose network interface and port can be configured separately.
|
||||
@@ -148,14 +246,31 @@ c.JupyterHub.hub_port = 54321
|
||||
|
||||
## Security
|
||||
|
||||
Security is the most important aspect of configuring Jupyter. There are three main aspects of the
|
||||
**IMPORTANT: You should not run JupyterHub without SSL encryption on a public network.**
|
||||
|
||||
---
|
||||
|
||||
**Deprecation note:** Removed `--no-ssl` in version 0.7.
|
||||
|
||||
JupyterHub versions 0.5 and 0.6 require extra confirmation via `--no-ssl` to
|
||||
allow running without SSL using the command `jupyterhub --no-ssl`. The
|
||||
`--no-ssl` command line option is not needed anymore in version 0.7.
|
||||
|
||||
---
|
||||
|
||||
Security is the most important aspect of configuring Jupyter. There are four main aspects of the
|
||||
security configuration:
|
||||
|
||||
1. SSL encryption (to enable HTTPS)
|
||||
2. Cookie secret (a key for encrypting browser cookies)
|
||||
3. Proxy authentication token (used for the Hub and other services to authenticate to the Proxy)
|
||||
4. Periodic security audits
|
||||
|
||||
## SSL encryption
|
||||
*Note* that the **Hub** hashes all secrets (e.g., auth tokens) before storing them in its
|
||||
database. A loss of control over read-access to the database should have no security impact
|
||||
on your deployment.
|
||||
|
||||
### SSL encryption
|
||||
|
||||
Since JupyterHub includes authentication and allows arbitrary code execution, you should not run
|
||||
it without SSL (HTTPS). This will require you to obtain an official, trusted SSL certificate or
|
||||
@@ -167,19 +282,35 @@ c.JupyterHub.ssl_key = '/path/to/my.key'
|
||||
c.JupyterHub.ssl_cert = '/path/to/my.cert'
|
||||
```
|
||||
|
||||
It is also possible to use letsencrypt (https://letsencrypt.org/) to obtain a free, trusted SSL
|
||||
certificate. If you run letsencrypt using the default options, the needed configuration is (replace `your.domain.com` by your fully qualified domain name):
|
||||
It is also possible to use letsencrypt (https://letsencrypt.org/) to obtain
|
||||
a free, trusted SSL certificate. If you run letsencrypt using the default
|
||||
options, the needed configuration is (replace `mydomain.tld` by your fully
|
||||
qualified domain name):
|
||||
|
||||
```python
|
||||
c.JupyterHub.ssl_key = '/etc/letsencrypt/live/your.domain.com/privkey.pem'
|
||||
c.JupyterHub.ssl_cert = '/etc/letsencrypt/live/your.domain.com/fullchain.pem'
|
||||
c.JupyterHub.ssl_key = '/etc/letsencrypt/live/{mydomain.tld}/privkey.pem'
|
||||
c.JupyterHub.ssl_cert = '/etc/letsencrypt/live/{mydomain.tld}/fullchain.pem'
|
||||
```
|
||||
|
||||
If the fully qualified domain name (FQDN) is `example.com`, the following
|
||||
would be the needed configuration:
|
||||
|
||||
```python
|
||||
c.JupyterHub.ssl_key = '/etc/letsencrypt/live/example.com/privkey.pem'
|
||||
c.JupyterHub.ssl_cert = '/etc/letsencrypt/live/example.com/fullchain.pem'
|
||||
```
|
||||
|
||||
Some cert files also contain the key, in which case only the cert is needed. It is important that
|
||||
these files be put in a secure location on your server, where they are not readable by regular
|
||||
users.
|
||||
|
||||
## Cookie secret
|
||||
Note on **chain certificates**: If you are using a chain certificate, see also
|
||||
[chained certificate for SSL](troubleshooting.md#chained-certificates-for-ssl) in the JupyterHub troubleshooting FAQ).
|
||||
|
||||
Note: In certain cases, e.g. **behind SSL termination in nginx**, allowing no SSL
|
||||
running on the hub may be desired.
|
||||
|
||||
### Cookie secret
|
||||
|
||||
The cookie secret is an encryption key, used to encrypt the browser cookies used for
|
||||
authentication. If this value changes for the Hub, all single-user servers must also be restarted.
|
||||
@@ -190,30 +321,45 @@ as follows:
|
||||
c.JupyterHub.cookie_secret_file = '/srv/jupyterhub/cookie_secret'
|
||||
```
|
||||
|
||||
The content of this file should be a long random string. An example would be to generate this
|
||||
file as:
|
||||
The content of this file should be a long random string encoded in MIME Base64. An example would be to generate this file as:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 1024 > /srv/jupyterhub/cookie_secret
|
||||
openssl rand -base64 2048 > /srv/jupyterhub/cookie_secret
|
||||
```
|
||||
|
||||
In most deployments of JupyterHub, you should point this to a secure location on the file
|
||||
system, such as `/srv/jupyterhub/cookie_secret`. If the cookie secret file doesn't exist when
|
||||
the Hub starts, a new cookie secret is generated and stored in the file. The recommended
|
||||
permissions for the cookie secret file should be 600 (owner-only rw).
|
||||
the Hub starts, a new cookie secret is generated and stored in the file. The
|
||||
file must not be readable by group or other or the server won't start.
|
||||
The recommended permissions for the cookie secret file are 600 (owner-only rw).
|
||||
|
||||
|
||||
If you would like to avoid the need for files, the value can be loaded in the Hub process from
|
||||
the `JPY_COOKIE_SECRET` environment variable:
|
||||
the `JPY_COOKIE_SECRET` environment variable, which is a hex-encoded string. You
|
||||
can set it this way:
|
||||
|
||||
```bash
|
||||
export JPY_COOKIE_SECRET=`openssl rand -hex 1024`
|
||||
```
|
||||
|
||||
For security reasons, this environment variable should only be visible to the Hub.
|
||||
If you set it dynamically as above, all users will be logged out each time the
|
||||
Hub starts.
|
||||
|
||||
## Proxy authentication token
|
||||
You can also set the cookie secret in the configuration file itself,`jupyterhub_config.py`,
|
||||
as a binary string:
|
||||
|
||||
The Hub authenticates its requests to the Proxy using a secret token that the Hub and Proxy agree upon. The value of this string should be a random string (for example, generated by `openssl rand -hex 32`). You can pass this value to the Hub and Proxy using either the `CONFIGPROXY_AUTH_TOKEN` environment variable:
|
||||
```python
|
||||
c.JupyterHub.cookie_secret = bytes.fromhex('VERY LONG SECRET HEX STRING')
|
||||
```
|
||||
|
||||
### Proxy authentication token
|
||||
|
||||
The Hub authenticates its requests to the Proxy using a secret token that
|
||||
the Hub and Proxy agree upon. The value of this string should be a random
|
||||
string (for example, generated by `openssl rand -hex 32`). You can pass
|
||||
this value to the Hub and Proxy using either the `CONFIGPROXY_AUTH_TOKEN`
|
||||
environment variable:
|
||||
|
||||
```bash
|
||||
export CONFIGPROXY_AUTH_TOKEN=`openssl rand -hex 32`
|
||||
@@ -221,7 +367,7 @@ export CONFIGPROXY_AUTH_TOKEN=`openssl rand -hex 32`
|
||||
|
||||
This environment variable needs to be visible to the Hub and Proxy.
|
||||
|
||||
Or you can set the value in the configuration file:
|
||||
Or you can set the value in the configuration file, `jupyterhub_config.py`:
|
||||
|
||||
```python
|
||||
c.JupyterHub.proxy_auth_token = '0bc02bede919e99a26de1e2a7a5aadfaf6228de836ec39a05a6c6942831d8fe5'
|
||||
@@ -231,12 +377,27 @@ If you don't set the Proxy authentication token, the Hub will generate a random
|
||||
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).
|
||||
|
||||
Another time you must set the Proxy authentication token yourself is if you want other services, such as [nbgrader](https://github.com/jupyter/nbgrader) to also be able to connect to the Proxy.
|
||||
Another time you must set the Proxy authentication token yourself is if
|
||||
you want other services, such as [nbgrader](https://github.com/jupyter/nbgrader)
|
||||
to also be able to connect to the Proxy.
|
||||
|
||||
## Configuring authentication
|
||||
### Security audits
|
||||
|
||||
We recommend that you do periodic reviews of your deployment's security. It's
|
||||
good practice to keep JupyterHub, configurable-http-proxy, and nodejs
|
||||
versions up to date.
|
||||
|
||||
A handy website for testing your deployment is
|
||||
[Qualsys' SSL analyzer tool](https://www.ssllabs.com/ssltest/analyze.html).
|
||||
|
||||
## Authentication and users
|
||||
|
||||
The default Authenticator uses [PAM][] to authenticate system users with
|
||||
their username and password. The default behavior of this Authenticator
|
||||
is to allow any user with an account and password on the system to login.
|
||||
|
||||
### Creating a whitelist of users
|
||||
|
||||
The default Authenticator uses [PAM][] to authenticate system users with their username and password.
|
||||
The default behavior of this Authenticator is to allow any user with an account and password on the system to login.
|
||||
You can restrict which users are allowed to login with `Authenticator.whitelist`:
|
||||
|
||||
|
||||
@@ -244,6 +405,8 @@ You can restrict which users are allowed to login with `Authenticator.whitelist`
|
||||
c.Authenticator.whitelist = {'mal', 'zoe', 'inara', 'kaylee'}
|
||||
```
|
||||
|
||||
### Managing Hub administrators
|
||||
|
||||
Admin users of JupyterHub have the ability to take actions on users' behalf,
|
||||
such as stopping and restarting their servers,
|
||||
and adding and removing new users from the whitelist.
|
||||
@@ -259,7 +422,10 @@ If `JupyterHub.admin_access` is True (not default),
|
||||
then admin users have permission to log in *as other users* on their respective machines, for debugging.
|
||||
**You should make sure your users know if admin_access is enabled.**
|
||||
|
||||
### Adding and removing users
|
||||
Note: additional configuration examples are provided in this guide's
|
||||
[Configuration Examples section](./config-examples.html).
|
||||
|
||||
### Add or remove users from the Hub
|
||||
|
||||
Users can be added and removed to the Hub via the admin panel or REST API. These users will be
|
||||
added to the whitelist and database. Restarting the Hub will not require manually updating the
|
||||
@@ -283,7 +449,7 @@ hosted deployments of JupyterHub, to avoid the need to manually create all your
|
||||
launching the service. It is not recommended when running JupyterHub in situations where
|
||||
JupyterHub users maps directly onto UNIX users.
|
||||
|
||||
## Configuring single-user servers
|
||||
## Spawners and single-user notebook servers
|
||||
|
||||
Since the single-user server is an instance of `jupyter notebook`, an entire separate
|
||||
multi-process application, there are many aspect of that server can configure, and a lot of ways
|
||||
@@ -319,96 +485,42 @@ which is the place to put configuration that you want to affect all of your user
|
||||
|
||||
## External services
|
||||
|
||||
JupyterHub has a REST API that can be used to run external services.
|
||||
More detail on this API will be added in the future.
|
||||
JupyterHub has a REST API that can be used by external services like the
|
||||
[cull_idle_servers](https://github.com/jupyterhub/jupyterhub/blob/master/examples/cull-idle/cull_idle_servers.py)
|
||||
script which monitors and kills idle single-user servers periodically. In order to run such an
|
||||
external service, you need to provide it an API token. In the case of `cull_idle_servers`, it is passed
|
||||
as the environment variable called `JPY_API_TOKEN`.
|
||||
|
||||
## File locations
|
||||
|
||||
It is recommended to put all of the files used by JupyterHub into standard UNIX filesystem locations.
|
||||
|
||||
* `/srv/jupyterhub` for all security and runtime files
|
||||
* `/etc/jupyterhub` for all configuration files
|
||||
* `/var/log` for log files
|
||||
|
||||
## Example
|
||||
|
||||
In the following example, we show a configuration files for a fairly standard JupyterHub deployment with the following assumptions:
|
||||
|
||||
* JupyterHub is running on a single cloud server
|
||||
* Using SSL on the standard HTTPS port 443
|
||||
* You want to use [GitHub OAuth][oauthenticator] for login
|
||||
* You need the users to exist locally on the server
|
||||
* You want users' notebooks to be served from `~/assignments` to allow users to browse for notebooks within
|
||||
other users home directories
|
||||
* You want the landing page for each user to be a Welcome.ipynb notebook in their assignments directory.
|
||||
* All runtime files are put into `/srv/jupyterhub` and log files in `/var/log`.
|
||||
|
||||
Let's start out with `jupyterhub_config.py`:
|
||||
|
||||
```python
|
||||
# jupyterhub_config.py
|
||||
c = get_config()
|
||||
|
||||
import os
|
||||
pjoin = os.path.join
|
||||
|
||||
runtime_dir = os.path.join('/srv/jupyterhub')
|
||||
ssl_dir = pjoin(runtime_dir, 'ssl')
|
||||
if not os.path.exists(ssl_dir):
|
||||
os.makedirs(ssl_dir)
|
||||
|
||||
|
||||
# https on :443
|
||||
c.JupyterHub.port = 443
|
||||
c.JupyterHub.ssl_key = pjoin(ssl_dir, 'ssl.key')
|
||||
c.JupyterHub.ssl_cert = pjoin(ssl_dir, 'ssl.cert')
|
||||
|
||||
# put the JupyterHub cookie secret and state db
|
||||
# in /var/run/jupyterhub
|
||||
c.JupyterHub.cookie_secret_file = pjoin(runtime_dir, 'cookie_secret')
|
||||
c.JupyterHub.db_url = pjoin(runtime_dir, 'jupyterhub.sqlite')
|
||||
# or `--db=/path/to/jupyterhub.sqlite` on the command-line
|
||||
|
||||
# put the log file in /var/log
|
||||
c.JupyterHub.log_file = '/var/log/jupyterhub.log'
|
||||
|
||||
# use GitHub OAuthenticator for local users
|
||||
|
||||
c.JupyterHub.authenticator_class = 'oauthenticator.LocalGitHubOAuthenticator'
|
||||
c.GitHubOAuthenticator.oauth_callback_url = os.environ['OAUTH_CALLBACK_URL']
|
||||
# create system users that don't exist yet
|
||||
c.LocalAuthenticator.create_system_users = True
|
||||
|
||||
# specify users and admin
|
||||
c.Authenticator.whitelist = {'rgbkrk', 'minrk', 'jhamrick'}
|
||||
c.Authenticator.admin_users = {'jhamrick', 'rgbkrk'}
|
||||
|
||||
# start single-user notebook servers in ~/assignments,
|
||||
# with ~/assignments/Welcome.ipynb as the default landing page
|
||||
# this config could also be put in
|
||||
# /etc/ipython/ipython_notebook_config.py
|
||||
c.Spawner.notebook_dir = '~/assignments'
|
||||
c.Spawner.args = ['--NotebookApp.default_url=/notebooks/Welcome.ipynb']
|
||||
```
|
||||
|
||||
Using the GitHub Authenticator [requires a few additional env variables][oauth-setup],
|
||||
which we will need to set when we launch the server:
|
||||
Currently there are two ways of registering that token with JupyterHub. The first one is to use
|
||||
the `jupyterhub` command to generate a token for a specific hub user:
|
||||
|
||||
```bash
|
||||
export GITHUB_CLIENT_ID=github_id
|
||||
export GITHUB_CLIENT_SECRET=github_secret
|
||||
export OAUTH_CALLBACK_URL=https://example.com/hub/oauth_callback
|
||||
export CONFIGPROXY_AUTH_TOKEN=super-secret
|
||||
jupyterhub -f /path/to/aboveconfig.py
|
||||
jupyterhub token <username>
|
||||
```
|
||||
|
||||
# Further reading
|
||||
As of [version 0.6.0](./changelog.html), the preferred way of doing this is to first generate an API token:
|
||||
|
||||
- [Custom Authenticators](./authenticators.html)
|
||||
- [Custom Spawners](./spawners.html)
|
||||
- [Troubleshooting](./troubleshooting.html)
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
|
||||
[oauth-setup]: https://github.com/jupyter/oauthenticator#setup
|
||||
[oauthenticator]: https://github.com/jupyter/oauthenticator
|
||||
and then write it to your JupyterHub configuration file (note that the **key** is the token while the **value** is the username):
|
||||
|
||||
```python
|
||||
c.JupyterHub.api_tokens = {'token' : 'username'}
|
||||
```
|
||||
|
||||
Upon restarting JupyterHub, you should see a message like below in the logs:
|
||||
|
||||
```
|
||||
Adding API token for <username>
|
||||
```
|
||||
|
||||
Now you can run your script, i.e. `cull_idle_servers`, by providing it the API token and it will authenticate through
|
||||
the REST API to interact with it.
|
||||
|
||||
|
||||
[oauth-setup]: https://github.com/jupyterhub/oauthenticator#setup
|
||||
[oauthenticator]: https://github.com/jupyterhub/oauthenticator
|
||||
[PAM]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
|
||||
|
@@ -1,11 +1,11 @@
|
||||
# How JupyterHub works
|
||||
|
||||
JupyterHub is a multi-user server that manages and proxies multiple instances of the single-user <del>IPython</del> Jupyter notebook server.
|
||||
JupyterHub is a multi-user server that manages and proxies multiple instances of the single-user Jupyter notebook server.
|
||||
|
||||
There are three basic processes involved:
|
||||
|
||||
- multi-user Hub (Python/Tornado)
|
||||
- [configurable http proxy](https://github.com/jupyter/configurable-http-proxy) (node-http-proxy)
|
||||
- [configurable http proxy](https://github.com/jupyterhub/configurable-http-proxy) (node-http-proxy)
|
||||
- multiple single-user IPython notebook servers (Python/IPython/Tornado)
|
||||
|
||||
The proxy is the only process that listens on a public interface.
|
||||
@@ -57,9 +57,9 @@ or at least with access to the PAM service,
|
||||
which regular users typically do not have
|
||||
(on Ubuntu, this requires being added to the `shadow` group).
|
||||
|
||||
[More info on custom Authenticators](authenticators.md).
|
||||
[More info on custom Authenticators](authenticators.html).
|
||||
|
||||
See a list of custom Authenticators [on the wiki](https://github.com/jupyter/jupyterhub/wiki/Authenticators).
|
||||
See a list of custom Authenticators [on the wiki](https://github.com/jupyterhub/jupyterhub/wiki/Authenticators).
|
||||
|
||||
|
||||
### Spawning
|
||||
@@ -72,6 +72,6 @@ and needs to be able to take three actions:
|
||||
2. poll whether the process is still running
|
||||
3. stop the process
|
||||
|
||||
[More info on custom Spawners](spawners.md).
|
||||
[More info on custom Spawners](spawners.html).
|
||||
|
||||
See a list of custom Spawners [on the wiki](https://github.com/jupyter/jupyterhub/wiki/Spawners).
|
||||
See a list of custom Spawners [on the wiki](https://github.com/jupyterhub/jupyterhub/wiki/Spawners).
|
||||
|
BIN
docs/source/images/hub-pieces.png
Normal file
BIN
docs/source/images/hub-pieces.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 59 KiB |
BIN
docs/source/images/jhub-parts.png
Normal file
BIN
docs/source/images/jhub-parts.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 80 KiB |
Binary file not shown.
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 27 KiB |
@@ -1,81 +1,116 @@
|
||||
.. JupyterHub documentation master file, created by
|
||||
sphinx-quickstart on Mon Jan 4 16:31:09 2016.
|
||||
You can adapt this file completely to your liking, but it should at least
|
||||
contain the root `toctree` directive.
|
||||
|
||||
JupyterHub
|
||||
==========
|
||||
|
||||
.. note:: This is the official documentation for JupyterHub. This project is
|
||||
under active development.
|
||||
|
||||
JupyterHub is a multi-user server that manages and proxies multiple instances
|
||||
of the single-user Jupyter notebook server.
|
||||
|
||||
Three actors:
|
||||
|
||||
* multi-user Hub (tornado process)
|
||||
* `configurable http proxy <https://github.com/jupyter/configurable-http-proxy>`_ (node-http-proxy)
|
||||
* multiple single-user IPython notebook servers (Python/IPython/tornado)
|
||||
|
||||
Basic principles:
|
||||
|
||||
* Hub spawns proxy
|
||||
* Proxy forwards ~all requests to hub by default
|
||||
* Hub handles login, and spawns single-user servers on demand
|
||||
* Hub configures proxy to forward url prefixes to single-user servers
|
||||
With JupyterHub you can create a **multi-user Hub** which spawns, manages,
|
||||
and proxies multiple instances of the single-user
|
||||
`Jupyter notebook <https://jupyter-notebook.readthedocs.io/en/latest/>`_ server.
|
||||
Due to its flexibility and customization options, JupyterHub can be used to
|
||||
serve notebooks to a class of students, a corporate data science group, or a
|
||||
scientific research group.
|
||||
|
||||
|
||||
Contents:
|
||||
.. image:: images/jhub-parts.png
|
||||
:alt: JupyterHub subsystems
|
||||
:width: 40%
|
||||
:align: right
|
||||
|
||||
|
||||
Three subsystems make up JupyterHub:
|
||||
|
||||
* a multi-user **Hub** (tornado process)
|
||||
* a **configurable http proxy** (node-http-proxy)
|
||||
* multiple **single-user Jupyter notebook servers** (Python/IPython/tornado)
|
||||
|
||||
JupyterHub's basic flow of operations includes:
|
||||
|
||||
- The Hub spawns a proxy
|
||||
- The proxy forwards all requests to the Hub by default
|
||||
- The Hub handles user login and spawns single-user servers on demand
|
||||
- The Hub configures the proxy to forward URL prefixes to the single-user notebook servers
|
||||
|
||||
For convenient administration of the Hub, its users, and :doc:`services`
|
||||
(added in version 7.0), JupyterHub also provides a
|
||||
`REST API <http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/master/docs/rest-api.yml#!/default>`__.
|
||||
|
||||
Contents
|
||||
--------
|
||||
|
||||
**User Guide**
|
||||
|
||||
* :doc:`quickstart`
|
||||
* :doc:`getting-started`
|
||||
* :doc:`howitworks`
|
||||
* :doc:`websecurity`
|
||||
* :doc:`rest`
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: User Documentation
|
||||
:hidden:
|
||||
:caption: User Guide
|
||||
|
||||
quickstart
|
||||
getting-started
|
||||
howitworks
|
||||
websecurity
|
||||
rest
|
||||
|
||||
**Configuration Guide**
|
||||
|
||||
* :doc:`authenticators`
|
||||
* :doc:`spawners`
|
||||
* :doc:`services`
|
||||
* :doc:`config-examples`
|
||||
* :doc:`upgrading`
|
||||
* :doc:`troubleshooting`
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Configuration
|
||||
:hidden:
|
||||
:caption: Configuration Guide
|
||||
|
||||
authenticators
|
||||
spawners
|
||||
services
|
||||
config-examples
|
||||
upgrading
|
||||
troubleshooting
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:caption: Developer Documentation
|
||||
|
||||
api/index
|
||||
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:caption: Community documentation
|
||||
|
||||
**API Reference**
|
||||
|
||||
* :doc:`api/index`
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:hidden:
|
||||
:caption: API Reference
|
||||
|
||||
api/index
|
||||
|
||||
|
||||
**About JupyterHub**
|
||||
|
||||
* :doc:`changelog`
|
||||
* :doc:`contributor-list`
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:hidden:
|
||||
:caption: About JupyterHub
|
||||
|
||||
changelog
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:caption: Questions? Suggestions?
|
||||
|
||||
Jupyter mailing list <https://groups.google.com/forum/#!forum/jupyter>
|
||||
Jupyter website <https://jupyter.org>
|
||||
Stack Overflow - Jupyter <https://stackoverflow.com/questions/tagged/jupyter>
|
||||
Stack Overflow - Jupyter-notebook <https://stackoverflow.com/questions/tagged/jupyter-notebook>
|
||||
contributor-list
|
||||
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
------------------
|
||||
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
||||
|
||||
|
||||
Questions? Suggestions?
|
||||
-----------------------
|
||||
|
||||
- `Jupyter mailing list <https://groups.google.com/forum/#!forum/jupyter>`_
|
||||
- `Jupyter website <https://jupyter.org>`_
|
||||
|
160
docs/source/quickstart.md
Normal file
160
docs/source/quickstart.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# Quickstart - Installation
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**Before installing JupyterHub**, you will need:
|
||||
|
||||
- [Python](https://www.python.org/downloads/) 3.3 or greater
|
||||
|
||||
An understanding of using [`pip`](https://pip.pypa.io/en/stable/) or
|
||||
[`conda`](http://conda.pydata.org/docs/get-started.html) for
|
||||
installing Python packages is helpful.
|
||||
|
||||
- [nodejs/npm](https://www.npmjs.com/)
|
||||
|
||||
[Install nodejs/npm](https://docs.npmjs.com/getting-started/installing-node),
|
||||
using your operating system's package manager. For example, install on Linux
|
||||
(Debian/Ubuntu) using:
|
||||
|
||||
```bash
|
||||
sudo apt-get install npm nodejs-legacy
|
||||
```
|
||||
|
||||
(The `nodejs-legacy` package installs the `node` executable and is currently
|
||||
required for npm to work on Debian/Ubuntu.)
|
||||
|
||||
- TLS certificate and key for HTTPS communication
|
||||
|
||||
- Domain name
|
||||
|
||||
**Before running the single-user notebook servers** (which may be on the same
|
||||
system as the Hub or not):
|
||||
|
||||
- [Jupyter Notebook](https://jupyter.readthedocs.io/en/latest/install.html)
|
||||
version 4 or greater
|
||||
|
||||
## Installation
|
||||
|
||||
JupyterHub can be installed with `pip` or `conda` and the proxy with `npm`:
|
||||
|
||||
**pip, npm:**
|
||||
```bash
|
||||
python3 -m pip install jupyterhub
|
||||
npm install -g configurable-http-proxy
|
||||
```
|
||||
|
||||
**conda** (one command installs jupyterhub and proxy):
|
||||
```bash
|
||||
conda install -c conda-forge jupyterhub
|
||||
```
|
||||
|
||||
To test your installation:
|
||||
|
||||
```bash
|
||||
jupyterhub -h
|
||||
configurable-http-proxy -h
|
||||
```
|
||||
|
||||
If you plan to run notebook servers locally, you will need also to install
|
||||
Jupyter notebook:
|
||||
|
||||
**pip:**
|
||||
```bash
|
||||
python3 -m pip install notebook
|
||||
```
|
||||
|
||||
**conda:**
|
||||
```bash
|
||||
conda install notebook
|
||||
```
|
||||
|
||||
## Start the Hub server
|
||||
|
||||
To start the Hub server, run the command:
|
||||
|
||||
```bash
|
||||
jupyterhub
|
||||
```
|
||||
|
||||
Visit `https://localhost:8000` in your browser, and sign in with your unix
|
||||
credentials.
|
||||
|
||||
To allow multiple users to sign into the Hub server, you must start `jupyterhub` as a *privileged user*, such as root:
|
||||
|
||||
```bash
|
||||
sudo jupyterhub
|
||||
```
|
||||
|
||||
The [wiki](https://github.com/jupyterhub/jupyterhub/wiki/Using-sudo-to-run-JupyterHub-without-root-privileges)
|
||||
describes how to run the server as a *less privileged user*, which requires
|
||||
additional configuration of the system.
|
||||
|
||||
----
|
||||
|
||||
## Basic Configuration
|
||||
|
||||
The [getting started document](docs/source/getting-started.md) contains
|
||||
detailed information abouts configuring a JupyterHub deployment.
|
||||
|
||||
The JupyterHub **tutorial** provides a video and documentation that explains
|
||||
and illustrates the fundamental steps for installation and configuration.
|
||||
[Repo](https://github.com/jupyterhub/jupyterhub-tutorial)
|
||||
| [Tutorial documentation](http://jupyterhub-tutorial.readthedocs.io/en/latest/)
|
||||
|
||||
#### Generate a default configuration file
|
||||
|
||||
Generate a default config file:
|
||||
|
||||
jupyterhub --generate-config
|
||||
|
||||
#### Customize the configuration, authentication, and process spawning
|
||||
|
||||
Spawn the server on ``10.0.1.2:443`` with **https**:
|
||||
|
||||
jupyterhub --ip 10.0.1.2 --port 443 --ssl-key my_ssl.key --ssl-cert my_ssl.cert
|
||||
|
||||
The authentication and process spawning mechanisms can be replaced,
|
||||
which should allow plugging into a variety of authentication or process
|
||||
control environments. Some examples, meant as illustration and testing of this
|
||||
concept, are:
|
||||
|
||||
- Using GitHub OAuth instead of PAM with [OAuthenticator](https://github.com/jupyterhub/oauthenticator)
|
||||
- Spawning single-user servers with Docker, using the [DockerSpawner](https://github.com/jupyterhub/dockerspawner)
|
||||
|
||||
----
|
||||
|
||||
## Alternate Installation using Docker
|
||||
|
||||
A ready to go [docker image for JupyterHub](https://hub.docker.com/r/jupyterhub/jupyterhub/)
|
||||
gives a straightforward deployment of JupyterHub.
|
||||
|
||||
*Note: This `jupyterhub/jupyterhub` docker image is only an image for running
|
||||
the Hub service itself. It does not provide the other Jupyter components, such
|
||||
as Notebook installation, which are needed by the single-user servers.
|
||||
To run the single-user servers, which may be on the same system as the Hub or
|
||||
not, Jupyter Notebook version 4 or greater must be installed.*
|
||||
|
||||
#### Starting JupyterHub with docker
|
||||
|
||||
The JupyterHub docker image can be started with the following command:
|
||||
|
||||
docker run -d --name jupyterhub jupyterhub/jupyterhub jupyterhub
|
||||
|
||||
This command will create a container named `jupyterhub` that you can
|
||||
**stop and resume** with `docker stop/start`.
|
||||
|
||||
The Hub service will be listening on all interfaces at port 8000, which makes
|
||||
this a good choice for **testing JupyterHub on your desktop or laptop**.
|
||||
|
||||
If you want to run docker on a computer that has a public IP then you should
|
||||
(as in MUST) **secure it with ssl** by adding ssl options to your docker
|
||||
configuration or using a ssl enabled proxy.
|
||||
|
||||
[Mounting volumes](https://docs.docker.com/engine/userguide/containers/dockervolumes/)
|
||||
will allow you to **store data outside the docker image (host system) so it will be persistent**,
|
||||
even when you start a new image.
|
||||
|
||||
The command `docker exec -it jupyterhub bash` will spawn a root shell in your
|
||||
docker container. You can **use the root shell to create system users in the container**.
|
||||
These accounts will be used for authentication in JupyterHub's default
|
||||
configuration.
|
70
docs/source/rest.md
Normal file
70
docs/source/rest.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Using JupyterHub's REST API
|
||||
|
||||
Using the [JupyterHub REST API][], you can perform actions on the Hub,
|
||||
such as:
|
||||
|
||||
- checking which users are active
|
||||
- adding or removing users
|
||||
- stopping or starting single user notebook servers
|
||||
- authenticating services
|
||||
|
||||
A [REST](https://en.wikipedia.org/wiki/Representational_state_transfer)
|
||||
API provides a standard way for users to get and send information to the
|
||||
Hub.
|
||||
|
||||
|
||||
## Creating an API token
|
||||
To send requests using JupyterHub API, you must pass an API token with the
|
||||
request. You can create a token for an individual user using the following
|
||||
command:
|
||||
|
||||
jupyterhub token USERNAME
|
||||
|
||||
|
||||
## Adding tokens to the config file
|
||||
You may also add a dictionary of API tokens and usernames to the hub's
|
||||
configuration file, `jupyterhub_config.py`:
|
||||
|
||||
```python
|
||||
c.JupyterHub.api_tokens = {
|
||||
'secret-token': 'username',
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Making an API request
|
||||
|
||||
To authenticate your requests, pass the API token in the request's
|
||||
Authorization header.
|
||||
|
||||
**Example: List the hub's users**
|
||||
|
||||
Using the popular Python requests library, the following code sends an API
|
||||
request and an API token for authorization:
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
api_url = 'http://127.0.0.1:8081/hub/api'
|
||||
|
||||
r = requests.get(api_url + '/users',
|
||||
headers={
|
||||
'Authorization': 'token %s' % token,
|
||||
}
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
users = r.json()
|
||||
```
|
||||
|
||||
|
||||
## Learning more about the API
|
||||
|
||||
You can see the full [JupyterHub REST API][] for details.
|
||||
The same REST API Spec can be viewed in a more interactive style [on swagger's petstore][].
|
||||
Both resources contain the same information and differ only in its display.
|
||||
Note: The Swagger specification is being renamed the [OpenAPI Initiative][].
|
||||
|
||||
[on swagger's petstore]: http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/master/docs/rest-api.yml#!/default
|
||||
[OpenAPI Initiative]: https://www.openapis.org/
|
||||
[JupyterHub REST API]: ./_static/rest-api/index.html
|
357
docs/source/services.md
Normal file
357
docs/source/services.md
Normal file
@@ -0,0 +1,357 @@
|
||||
# Services
|
||||
|
||||
With version 0.7, JupyterHub adds support for **Services**.
|
||||
|
||||
This section provides the following information about Services:
|
||||
|
||||
- [Definition of a Service](services.html#definition-of-a-service)
|
||||
- [Properties of a Service](services.html#properties-of-a-service)
|
||||
- [Hub-Managed Services](services.html#hub-managed-services)
|
||||
- [Launching a Hub-Managed Service](services.html#launching-a-hub-managed-service)
|
||||
- [Externally-Managed Services](services.html#externally-managed-services)
|
||||
- [Writing your own Services](services.html#writing-your-own-services)
|
||||
- [Hub Authentication and Services](services.html#hub-authentication-and-services)
|
||||
|
||||
## Definition of a Service
|
||||
|
||||
When working with JupyterHub, a **Service** is defined as a process that interacts
|
||||
with the Hub's REST API. A Service may perform a specific or
|
||||
action or task. For example, the following tasks can each be a unique Service:
|
||||
|
||||
- shutting down individuals' single user notebook servers that have been idle
|
||||
for some time
|
||||
- registering additional web servers which should use the Hub's authentication
|
||||
and be served behind the Hub's proxy.
|
||||
|
||||
Two key features help define a Service:
|
||||
|
||||
- Is the Service **managed** by JupyterHub?
|
||||
- Does the Service have a web server that should be added to the proxy's
|
||||
table?
|
||||
|
||||
Currently, these characteristics distinguish two types of Services:
|
||||
|
||||
- A **Hub-Managed Service** which is managed by JupyterHub
|
||||
- An **Externally-Managed Service** which runs its own web server and
|
||||
communicates operation instructions via the Hub's API.
|
||||
|
||||
## Properties of a Service
|
||||
|
||||
A Service may have the following properties:
|
||||
|
||||
- `name: str` - the name of the service
|
||||
- `admin: bool (default - false)` - whether the service should have
|
||||
administrative privileges
|
||||
- `url: str (default - None)` - The URL where the service is/should be. If a
|
||||
url is specified for where the Service runs its own web server,
|
||||
the service will be added to the proxy at `/services/:name`
|
||||
|
||||
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.
|
||||
- Only use this if the service should be a subprocess.
|
||||
- If command is not specified, the Service is assumed to be managed
|
||||
externally.
|
||||
- If a command is specified for launching the Service, the Service will
|
||||
be started and managed by the Hub.
|
||||
- `environment: dict` - additional environment variables for the Service.
|
||||
- `user: str` - the name of a system user to manage the Service. If
|
||||
unspecified, run as the same user as the Hub.
|
||||
|
||||
## Hub-Managed Services
|
||||
|
||||
A **Hub-Managed Service** is started by the Hub, and the Hub is responsible
|
||||
for the Service's actions. A Hub-Managed Service can only be a local
|
||||
subprocess of the Hub. The Hub will take care of starting the process and
|
||||
restarts it if it stops.
|
||||
|
||||
While Hub-Managed Services share some similarities with notebook Spawners,
|
||||
there are no plans for Hub-Managed Services to support the same spawning
|
||||
abstractions as a notebook Spawner.
|
||||
|
||||
If you wish to run a Service in a Docker container or other deployment
|
||||
environments, the Service can be registered as an
|
||||
**Externally-Managed Service**, as described below.
|
||||
|
||||
## Launching a Hub-Managed Service
|
||||
|
||||
A Hub-Managed Service is characterized by its specified `command` for launching
|
||||
the Service. For example, a 'cull idle' notebook server task configured as a
|
||||
Hub-Managed Service would include:
|
||||
|
||||
- the Service name,
|
||||
- admin permissions, and
|
||||
- the `command` to launch the Service which will cull idle servers after a
|
||||
timeout interval
|
||||
|
||||
This example would be configured as follows in `jupyterhub_config.py`:
|
||||
|
||||
```python
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
'name': 'cull-idle',
|
||||
'admin': True,
|
||||
'command': ['python', '/path/to/cull-idle.py', '--timeout']
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
A Hub-Managed Service may also be configured with additional optional
|
||||
parameters, which describe the environment needed to start the Service process:
|
||||
|
||||
- `environment: dict` - additional environment variables for the Service.
|
||||
- `user: str` - name of the user to run the server if different from the Hub.
|
||||
Requires Hub to be root.
|
||||
- `cwd: path` directory in which to run the Service, if different from the
|
||||
Hub directory.
|
||||
|
||||
The Hub will pass the following environment variables to launch the Service:
|
||||
|
||||
```bash
|
||||
JUPYTERHUB_SERVICE_NAME: The name of the service
|
||||
JUPYTERHUB_API_TOKEN: API token assigned to the service
|
||||
JUPYTERHUB_API_URL: URL for the JupyterHub API (default, http://127.0.0.1:8080/hub/api)
|
||||
JUPYTERHUB_BASE_URL: Base URL of the Hub (https://mydomain[:port]/)
|
||||
JUPYTERHUB_SERVICE_PREFIX: URL path prefix of this service (/services/:service-name/)
|
||||
JUPYTERHUB_SERVICE_URL: Local URL where the service is expected to be listening.
|
||||
Only for proxied web services.
|
||||
```
|
||||
|
||||
For the previous 'cull idle' Service example, these environment variables
|
||||
would be passed to the Service when the Hub starts the 'cull idle' Service:
|
||||
|
||||
```bash
|
||||
JUPYTERHUB_SERVICE_NAME: 'cull-idle'
|
||||
JUPYTERHUB_API_TOKEN: API token assigned to the service
|
||||
JUPYTERHUB_API_URL: http://127.0.0.1:8080/hub/api
|
||||
JUPYTERHUB_BASE_URL: https://mydomain[:port]
|
||||
JUPYTERHUB_SERVICE_PREFIX: /services/cull-idle/
|
||||
```
|
||||
|
||||
See the JupyterHub GitHub repo for additional information about the
|
||||
[`cull-idle` example](https://github.com/jupyterhub/jupyterhub/tree/master/examples/cull-idle).
|
||||
|
||||
## Externally-Managed Services
|
||||
|
||||
You may prefer to use your own service management tools, such as Docker or
|
||||
systemd, to manage a JupyterHub Service. These **Externally-Managed
|
||||
Services**, unlike Hub-Managed Services, are not subprocesses of the Hub. You
|
||||
must tell JupyterHub which API token the Externally-Managed Service is using
|
||||
to perform its API requests. Each Externally-Managed Service will need a
|
||||
unique API token, because the Hub authenticates each API request and the API
|
||||
token is used to identify the originating Service or user.
|
||||
|
||||
A configuration example of an Externally-Managed Service with admin access and
|
||||
running its own web server is:
|
||||
|
||||
```python
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
'name': 'my-web-service',
|
||||
'url': 'https://10.0.1.1:1984',
|
||||
'api_token': 'super-secret',
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
In this case, the `url` field will be passed along to the Service as
|
||||
`JUPYTERHUB_SERVICE_URL`.
|
||||
|
||||
## Writing your own Services
|
||||
|
||||
When writing your own services, you have a few decisions to make (in addition
|
||||
to what your service does!):
|
||||
|
||||
1. Does my service need a public URL?
|
||||
2. Do I want JupyterHub to start/stop the service?
|
||||
3. Does my service need to authenticate users?
|
||||
|
||||
When a Service is managed by JupyterHub, the Hub will pass the necessary
|
||||
information to the Service via the environment variables described above. A
|
||||
flexible Service, whether managed by the Hub or not, can make use of these
|
||||
same environment variables.
|
||||
|
||||
When you run a service that has a url, it will be accessible under a
|
||||
`/services/` prefix, such as `https://myhub.horse/services/my-service/`. For
|
||||
your service to route proxied requests properly, it must take
|
||||
`JUPYTERHUB_SERVICE_PREFIX` into account when routing requests. For example, a
|
||||
web service would normally service its root handler at `'/'`, but the proxied
|
||||
service would need to serve `JUPYTERHUB_SERVICE_PREFIX + '/'`.
|
||||
|
||||
## Hub Authentication and Services
|
||||
|
||||
JupyterHub 0.7 introduces some utilities for using the Hub's authentication
|
||||
mechanism to govern access to your service. When a user logs into JupyterHub,
|
||||
the Hub sets a **cookie (`jupyterhub-services`)**. The service can use this
|
||||
cookie to authenticate requests.
|
||||
|
||||
JupyterHub ships with a reference implementation of Hub authentication that
|
||||
can be used by services. You may go beyond this reference implementation and
|
||||
create custom hub-authenticating clients and services. We describe the process
|
||||
below.
|
||||
|
||||
The reference, or base, implementation is the [`HubAuth`][HubAuth] class,
|
||||
which implements the requests to the Hub.
|
||||
|
||||
To use HubAuth, you must set the `.api_token`, either programmatically when constructing the class,
|
||||
or via the `JUPYTERHUB_API_TOKEN` environment variable.
|
||||
|
||||
Most of the logic for authentication implementation is found in the
|
||||
[`HubAuth.user_for_cookie`](services.auth.html#jupyterhub.services.auth.HubAuth.user_for_cookie)
|
||||
method, which makes a request of the Hub, and returns:
|
||||
|
||||
- None, if no user could be identified, or
|
||||
- a dict of the following form:
|
||||
|
||||
```python
|
||||
{
|
||||
"name": "username",
|
||||
"groups": ["list", "of", "groups"],
|
||||
"admin": False, # or True
|
||||
}
|
||||
```
|
||||
|
||||
You are then free to use the returned user information to take appropriate
|
||||
action.
|
||||
|
||||
HubAuth also caches the Hub's response for a number of seconds,
|
||||
configurable by the `cookie_cache_max_age` setting (default: five minutes).
|
||||
|
||||
### Flask Example
|
||||
|
||||
For example, you have a Flask service that returns information about a user.
|
||||
JupyterHub's HubAuth class can be used to authenticate requests to the Flask
|
||||
service. See the `service-whoami-flask` example in the
|
||||
[JupyterHub GitHub repo](https://github.com/jupyterhub/jupyterhub/tree/master/examples/service-whoami-flask)
|
||||
for more details.
|
||||
|
||||
```python
|
||||
from functools import wraps
|
||||
import json
|
||||
import os
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import Flask, redirect, request, Response
|
||||
|
||||
from jupyterhub.services.auth import HubAuth
|
||||
|
||||
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
|
||||
|
||||
auth = HubAuth(
|
||||
api_token=os.environ['JUPYTERHUB_API_TOKEN'],
|
||||
cookie_cache_max_age=60,
|
||||
)
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
def authenticated(f):
|
||||
"""Decorator for authenticating with the Hub"""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
cookie = request.cookies.get(auth.cookie_name)
|
||||
if cookie:
|
||||
user = auth.user_for_cookie(cookie)
|
||||
else:
|
||||
user = None
|
||||
if user:
|
||||
return f(user, *args, **kwargs)
|
||||
else:
|
||||
# redirect to login url on failed auth
|
||||
return redirect(auth.login_url + '?next=%s' % quote(request.path))
|
||||
return decorated
|
||||
|
||||
|
||||
@app.route(prefix + '/')
|
||||
@authenticated
|
||||
def whoami(user):
|
||||
return Response(
|
||||
json.dumps(user, indent=1, sort_keys=True),
|
||||
mimetype='application/json',
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
### Authenticating tornado services with JupyterHub
|
||||
|
||||
Since most Jupyter services are written with tornado,
|
||||
we include a mixin class, [`HubAuthenticated`][HubAuthenticated],
|
||||
for quickly authenticating your own tornado services with JupyterHub.
|
||||
|
||||
Tornado's `@web.authenticated` method calls a Handler's `.get_current_user`
|
||||
method to identify the user. Mixing in `HubAuthenticated` defines
|
||||
`get_current_user` to use HubAuth. If you want to configure the HubAuth
|
||||
instance beyond the default, you'll want to define an `initialize` method,
|
||||
such as:
|
||||
|
||||
```python
|
||||
class MyHandler(HubAuthenticated, web.RequestHandler):
|
||||
hub_users = {'inara', 'mal'}
|
||||
|
||||
def initialize(self, hub_auth):
|
||||
self.hub_auth = hub_auth
|
||||
|
||||
@web.authenticated
|
||||
def get(self):
|
||||
...
|
||||
```
|
||||
|
||||
|
||||
The HubAuth will automatically load the desired configuration from the Service
|
||||
environment variables.
|
||||
|
||||
If you want to limit user access, you can whitelist users through either 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
|
||||
list nor the group list, they will not be allowed access. If both are left
|
||||
undefined, then any user will be allowed.
|
||||
|
||||
|
||||
### Implementing your own Authentication with JupyterHub
|
||||
|
||||
If you don't want to use the reference implementation
|
||||
(e.g. you find the implementation a poor fit for your Flask app),
|
||||
you can implement authentication via the Hub yourself.
|
||||
We recommend looking at the [`HubAuth`][HubAuth] class implementation for reference,
|
||||
and taking note of the following process:
|
||||
|
||||
1. retrieve the cookie `jupyterhub-services` from the request.
|
||||
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.
|
||||
This request must be authenticated with a Hub API token in the `Authorization` header.
|
||||
For example, with [requests][]:
|
||||
|
||||
```python
|
||||
r = requests.get(
|
||||
'/'.join((["http://127.0.0.1:8081/hub/api",
|
||||
"authorizations/cookie/jupyterhub-services",
|
||||
quote(encrypted_cookie, safe=''),
|
||||
]),
|
||||
headers = {
|
||||
'Authorization' : 'token %s' % api_token,
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
user = r.json()
|
||||
```
|
||||
|
||||
3. On success, the reply will be a JSON model describing the user:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "inara",
|
||||
"groups": ["serenity", "guild"],
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
An example of using an Externally-Managed Service and authentication is
|
||||
[nbviewer](https://github.com/jupyter/nbviewer#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).
|
||||
nbviewer can also be run as a Hub-Managed Service as described [here](https://github.com/jupyter/nbviewer#securing-the-notebook-viewer).
|
||||
|
||||
|
||||
[requests]: http://docs.python-requests.org/en/master/
|
||||
[services_auth]: api/services.auth.html
|
||||
[HubAuth]: api/services.auth.html#jupyterhub.services.auth.HubAuth
|
||||
[HubAuthenticated]: api/services.auth.html#jupyterhub.services.auth.HubAuthenticated
|
@@ -1,4 +1,4 @@
|
||||
# Writing a custom Spawner
|
||||
# Spawners
|
||||
|
||||
A [Spawner][] starts each single-user notebook server.
|
||||
The Spawner represents an abstract interface to a process,
|
||||
@@ -8,21 +8,25 @@ and a custom Spawner needs to be able to take three actions:
|
||||
- poll whether the process is still running
|
||||
- stop the process
|
||||
|
||||
|
||||
## Examples
|
||||
Custom Spawners for JupyterHub can be found on the [JupyterHub wiki](https://github.com/jupyter/jupyterhub/wiki/Spawners). Some examples include:
|
||||
- [DockerSpawner](https://github.com/jupyter/dockerspawner) for spawning user servers in Docker containers
|
||||
* dockerspawner.DockerSpawner for spawning identical Docker containers for
|
||||
Custom Spawners for JupyterHub can be found on the [JupyterHub wiki](https://github.com/jupyterhub/jupyterhub/wiki/Spawners).
|
||||
Some examples include:
|
||||
|
||||
- [DockerSpawner](https://github.com/jupyterhub/dockerspawner) for spawning user servers in Docker containers
|
||||
* `dockerspawner.DockerSpawner` for spawning identical Docker containers for
|
||||
each users
|
||||
* dockerspawner.SystemUserSpawner for spawning Docker containers with an
|
||||
* `dockerspawner.SystemUserSpawner` for spawning Docker containers with an
|
||||
environment and home directory for each users
|
||||
- [SudoSpawner](https://github.com/jupyter/sudospawner) enables JupyterHub to
|
||||
* both `DockerSpawner` and `SystemUserSpawner` also work with Docker Swarm for
|
||||
launching containers on remote machines
|
||||
- [SudoSpawner](https://github.com/jupyterhub/sudospawner) enables JupyterHub to
|
||||
run without being root, by spawning an intermediate process via `sudo`
|
||||
- [BatchSpawner](https://github.com/mbmilligan/batchspawner) for spawning remote
|
||||
- [BatchSpawner](https://github.com/jupyterhub/batchspawner) for spawning remote
|
||||
servers using batch systems
|
||||
- [RemoteSpawner](https://github.com/zonca/remotespawner) to spawn notebooks
|
||||
and a remote server and tunnel the port via SSH
|
||||
- [SwarmSpawner](https://github.com/compmodels/jupyterhub/blob/master/swarmspawner.py)
|
||||
for spawning containers using Docker Swarm
|
||||
|
||||
|
||||
## Spawner control methods
|
||||
|
||||
@@ -61,11 +65,11 @@ and an integer exit status, otherwise.
|
||||
For the local process case, `Spawner.poll` uses `os.kill(PID, 0)`
|
||||
to check if the local process is still running.
|
||||
|
||||
|
||||
### Spawner.stop
|
||||
|
||||
`Spawner.stop` should stop the process. It must be a tornado coroutine, which should return when the process has finished exiting.
|
||||
|
||||
|
||||
## Spawner state
|
||||
|
||||
JupyterHub should be able to stop and restart without tearing down
|
||||
@@ -97,6 +101,7 @@ def clear_state(self):
|
||||
self.pid = 0
|
||||
```
|
||||
|
||||
|
||||
## Spawner options form
|
||||
|
||||
(new in 0.4)
|
||||
@@ -113,8 +118,7 @@ If the `Spawner.options_form` is defined, when a user tries to start their serve
|
||||
|
||||
If `Spawner.options_form` is undefined, the user's server is spawned directly, and no spawn page is rendered.
|
||||
|
||||
See [this example](https://github.com/jupyter/jupyterhub/blob/master/examples/spawn-form/jupyterhub_config.py) for a form that allows custom CLI args for the local spawner.
|
||||
|
||||
See [this example](https://github.com/jupyterhub/jupyterhub/blob/master/examples/spawn-form/jupyterhub_config.py) for a form that allows custom CLI args for the local spawner.
|
||||
|
||||
### `Spawner.options_from_form`
|
||||
|
||||
@@ -153,8 +157,58 @@ which would return:
|
||||
}
|
||||
```
|
||||
|
||||
When `Spawner.spawn` is called, this dictionary is accessible as `self.user_options`.
|
||||
When `Spawner.start` is called, this dictionary is accessible as `self.user_options`.
|
||||
|
||||
|
||||
[Spawner]: https://github.com/jupyterhub/jupyterhub/blob/master/jupyterhub/spawner.py
|
||||
|
||||
[Spawner]: https://github.com/jupyter/jupyterhub/blob/master/jupyterhub/spawner.py
|
||||
## Writing a custom spawner
|
||||
|
||||
If you are interested in building a custom spawner, you can read [this tutorial](http://jupyterhub-tutorial.readthedocs.io/en/latest/spawners.html).
|
||||
|
||||
## Spawners, resource limits, and guarantees (Optional)
|
||||
|
||||
Some spawners of the single-user notebook servers allow setting limits or
|
||||
guarantees on resources, such as CPU and memory. To provide a consistent
|
||||
experience for sysadmins and users, we provide a standard way to set and
|
||||
discover these resource limits and guarantees, such as for memory and CPU. For
|
||||
the limits and guarantees to be useful, the spawner must implement support for
|
||||
them.
|
||||
|
||||
### Memory Limits & Guarantees
|
||||
|
||||
`c.Spawner.mem_limit`: A **limit** specifies the *maximum amount of memory*
|
||||
that may be allocated, though there is no promise that the maximum amount will
|
||||
be available. In supported spawners, you can set `c.Spawner.mem_limit` to
|
||||
limit the total amount of memory that a single-user notebook server can
|
||||
allocate. Attempting to use more memory than this limit will cause errors. The
|
||||
single-user notebook server can discover its own memory limit by looking at
|
||||
the environment variable `MEM_LIMIT`, which is specified in absolute bytes.
|
||||
|
||||
`c.Spawner.mem_guarantee`: Sometimes, a **guarantee** of a *minumum amount of
|
||||
memory* is desirable. In this case, you can set `c.Spawner.mem_guarantee` to
|
||||
to provide a guarantee that at minimum this much memory will always be
|
||||
available for the single-user notebook server to use. The environment variable
|
||||
`MEM_GUARANTEE` will also be set in the single-user notebook server.
|
||||
|
||||
The spawner's underlying system or cluster is responsible for enforcing these
|
||||
limits and providing these guarantees. If these values are set to `None`, no
|
||||
limits or guarantees are provided, and no environment values are set.
|
||||
|
||||
### CPU Limits & Guarantees
|
||||
|
||||
`c.Spawner.cpu_limit`: In supported spawners, you can set
|
||||
`c.Spawner.cpu_limit` to limit the total number of cpu-cores that a
|
||||
single-user notebook server can use. These can be fractional - `0.5` means 50%
|
||||
of one CPU core, `4.0` is 4 cpu-cores, etc. This value is also set in the
|
||||
single-user notebook server's environment variable `CPU_LIMIT`. The limit does
|
||||
not claim that you will be able to use all the CPU up to your limit as other
|
||||
higher priority applications might be taking up CPU.
|
||||
|
||||
`c.Spawner.cpu_guarantee`: You can set `c.Spawner.cpu_guarantee` to provide a
|
||||
guarantee for CPU usage. The environment variable `CPU_GUARANTEE` will be set
|
||||
in the single-user notebook server when a guarantee is being provided.
|
||||
|
||||
The spawner's underlying system or cluster is responsible for enforcing these
|
||||
limits and providing these guarantees. If these values are set to `None`, no
|
||||
limits or guarantees are provided, and no environment values are set.
|
||||
|
213
docs/source/spelling_wordlist.txt
Normal file
213
docs/source/spelling_wordlist.txt
Normal file
@@ -0,0 +1,213 @@
|
||||
admin
|
||||
Afterwards
|
||||
alchemyst
|
||||
alope
|
||||
api
|
||||
API
|
||||
apps
|
||||
args
|
||||
asctime
|
||||
auth
|
||||
authenticator
|
||||
Authenticator
|
||||
authenticators
|
||||
Authenticators
|
||||
Autograde
|
||||
autograde
|
||||
autogradeapp
|
||||
autograded
|
||||
Autograded
|
||||
autograder
|
||||
Autograder
|
||||
autograding
|
||||
backends
|
||||
Bitdiddle
|
||||
bugfix
|
||||
Bugfixes
|
||||
bugtracker
|
||||
Carreau
|
||||
Changelog
|
||||
changelog
|
||||
checksum
|
||||
checksums
|
||||
cmd
|
||||
cogsci
|
||||
conda
|
||||
config
|
||||
coroutine
|
||||
coroutines
|
||||
crt
|
||||
customizable
|
||||
datefmt
|
||||
decrypted
|
||||
dev
|
||||
DockerSpawner
|
||||
dockerspawner
|
||||
dropdown
|
||||
duedate
|
||||
Duedate
|
||||
ellachao
|
||||
ellisonbg
|
||||
entrypoint
|
||||
env
|
||||
Filenames
|
||||
filesystem
|
||||
formatters
|
||||
formdata
|
||||
formgrade
|
||||
formgrader
|
||||
gif
|
||||
GitHub
|
||||
Gradebook
|
||||
gradebook
|
||||
Granger
|
||||
hardcoded
|
||||
hOlle
|
||||
Homebrew
|
||||
html
|
||||
http
|
||||
https
|
||||
hubapi
|
||||
Indices
|
||||
IFramed
|
||||
inline
|
||||
iopub
|
||||
ip
|
||||
ipynb
|
||||
IPython
|
||||
ischurov
|
||||
ivanslapnicar
|
||||
jdfreder
|
||||
jhamrick
|
||||
jklymak
|
||||
jonathanmorgan
|
||||
joschu
|
||||
JUPYTER
|
||||
Jupyter
|
||||
jupyter
|
||||
jupyterhub
|
||||
Kerberos
|
||||
kerberos
|
||||
letsencrypt
|
||||
lgpage
|
||||
linkcheck
|
||||
linux
|
||||
localhost
|
||||
logfile
|
||||
login
|
||||
logins
|
||||
logout
|
||||
lookup
|
||||
lphk
|
||||
mandli
|
||||
Marr
|
||||
mathjax
|
||||
matplotlib
|
||||
metadata
|
||||
mikebolt
|
||||
minrk
|
||||
Mitigations
|
||||
mixin
|
||||
Mixin
|
||||
multi
|
||||
multiuser
|
||||
namespace
|
||||
nbconvert
|
||||
nbgrader
|
||||
neuroscience
|
||||
nginx
|
||||
np
|
||||
npm
|
||||
oauth
|
||||
OAuth
|
||||
oauthenticator
|
||||
ok
|
||||
olgabot
|
||||
osx
|
||||
PAM
|
||||
phantomjs
|
||||
Phantomjs
|
||||
plugin
|
||||
plugins
|
||||
Popen
|
||||
positionally
|
||||
postgres
|
||||
pregenerated
|
||||
prepend
|
||||
prepopulate
|
||||
preprocessor
|
||||
Preprocessor
|
||||
prev
|
||||
Programmatically
|
||||
programmatically
|
||||
ps
|
||||
py
|
||||
Qualys
|
||||
quickstart
|
||||
readonly
|
||||
redSlug
|
||||
reinstall
|
||||
resize
|
||||
rst
|
||||
runtime
|
||||
rw
|
||||
sandboxed
|
||||
sansary
|
||||
singleuser
|
||||
smeylan
|
||||
spawner
|
||||
Spawner
|
||||
spawners
|
||||
Spawners
|
||||
spellcheck
|
||||
SQL
|
||||
sqlite
|
||||
startup
|
||||
statsd
|
||||
stdin
|
||||
stdout
|
||||
stoppped
|
||||
subclasses
|
||||
subcommand
|
||||
subdomain
|
||||
subdomains
|
||||
Subdomains
|
||||
suchow
|
||||
suprocesses
|
||||
svurens
|
||||
sys
|
||||
SystemUserSpawner
|
||||
systemwide
|
||||
tasilb
|
||||
teardown
|
||||
threadsafe
|
||||
timestamp
|
||||
timestamps
|
||||
TLD
|
||||
todo
|
||||
toolbar
|
||||
traitlets
|
||||
travis
|
||||
tuples
|
||||
undeletable
|
||||
unicode
|
||||
uninstall
|
||||
UNIX
|
||||
unix
|
||||
untracked
|
||||
untrusted
|
||||
url
|
||||
username
|
||||
usernames
|
||||
utcnow
|
||||
utils
|
||||
vinaykola
|
||||
virtualenv
|
||||
whitelist
|
||||
whitespace
|
||||
wildcard
|
||||
Wildcards
|
||||
willingc
|
||||
wordlist
|
||||
Workflow
|
||||
workflow
|
@@ -1,11 +1,274 @@
|
||||
# Troubleshooting
|
||||
|
||||
This document is under active development.
|
||||
When troubleshooting, you may see unexpected behaviors or receive an error
|
||||
message. This section provide links for identifying the cause of the
|
||||
problem and how to resolve it.
|
||||
|
||||
## Networking
|
||||
[*Behavior*](#behavior)
|
||||
- JupyterHub proxy fails to start
|
||||
- sudospawner fails to run
|
||||
|
||||
If JupyterHub proxy fails to start:
|
||||
[*Errors*](#errors)
|
||||
- 500 error after spawning my single-user server
|
||||
|
||||
[*How do I...?*](#how-do-i)
|
||||
- Use a chained SSL certificate
|
||||
- Install JupyterHub without a network connection
|
||||
- I want access to the whole filesystem, but still default users to their home directory
|
||||
- How do I increase the number of pySpark executors on YARN?
|
||||
- How do I use JupyterLab's prerelease version with JupyterHub?
|
||||
- How do I set up JupyterHub for a workshop (when users are not known ahead of time)?
|
||||
|
||||
[*Troubleshooting commands*](#troubleshooting-commands)
|
||||
|
||||
## Behavior
|
||||
|
||||
### JupyterHub proxy fails to start
|
||||
|
||||
If you have tried to start the JupyterHub proxy and it fails to start:
|
||||
|
||||
- check if the JupyterHub IP configuration setting is
|
||||
``c.JupyterHub.ip = '*'``; if it is, try ``c.JupyterHub.ip = ''``
|
||||
- Try starting with ``jupyterhub --ip=0.0.0.0``
|
||||
- Try starting with ``jupyterhub --ip=0.0.0.0``
|
||||
|
||||
### sudospawner fails to run
|
||||
|
||||
If the sudospawner script is not found in the path, sudospawner will not run.
|
||||
To avoid this, specify sudospawner's absolute path. For example, start
|
||||
jupyterhub with:
|
||||
|
||||
jupyterhub --SudoSpawner.sudospawner_path='/absolute/path/to/sudospawner'
|
||||
|
||||
or add:
|
||||
|
||||
c.SudoSpawner.sudospawner_path = '/absolute/path/to/sudospawner'
|
||||
|
||||
to the config file, `jupyterhub_config.py`.
|
||||
|
||||
## Errors
|
||||
|
||||
### 500 error after spawning my single-user server
|
||||
|
||||
You receive a 500 error when accessing the URL `/user/<your_name>/...`.
|
||||
This is often seen when your single-user server cannot verify your user cookie
|
||||
with the Hub.
|
||||
|
||||
There are two likely reasons for this:
|
||||
|
||||
1. The single-user server cannot connect to the Hub's API (networking
|
||||
configuration problems)
|
||||
2. The single-user server cannot *authenticate* its requests (invalid token)
|
||||
|
||||
#### Symptoms
|
||||
|
||||
The main symptom is a failure to load *any* page served by the single-user
|
||||
server, met with a 500 error. This is typically the first page at `/user/<your_name>`
|
||||
after logging in or clicking "Start my server". When a single-user notebook server
|
||||
receives a request, the notebook server makes an API request to the Hub to
|
||||
check if the cookie corresponds to the right user. This request is logged.
|
||||
|
||||
If everything is working, the response logged will be similar to this:
|
||||
|
||||
```
|
||||
200 GET /hub/api/authorizations/cookie/jupyter-hub-token-name/[secret] (@10.0.1.4) 6.10ms
|
||||
```
|
||||
|
||||
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
|
||||
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:
|
||||
|
||||
```
|
||||
403 GET /hub/api/authorizations/cookie/jupyter-hub-token-name/[secret] (@10.0.1.4) 4.14ms
|
||||
```
|
||||
|
||||
Check the logs of the single-user notebook server, which may have more detailed
|
||||
information on the cause.
|
||||
|
||||
#### Causes and resolutions
|
||||
|
||||
##### No authorization request
|
||||
|
||||
If you make an API request and it is not received by the server, you likely
|
||||
have a network configuration issue. Often, this happens when the Hub is only
|
||||
listening on 127.0.0.1 (default) and the single-user servers are not on the
|
||||
same 'machine' (can be physically remote, or in a docker container or VM). The
|
||||
fix for this case is to make sure that `c.JupyterHub.hub_ip` is an address
|
||||
that all single-user servers can connect to, e.g.:
|
||||
|
||||
```python
|
||||
c.JupyterHub.hub_ip = '10.0.0.1'
|
||||
```
|
||||
|
||||
##### 403 GET /hub/api/authorizations/cookie
|
||||
|
||||
If you receive a 403 error, the API token for the single-user server is likely
|
||||
invalid. Commonly, the 403 error is caused by resetting the JupyterHub
|
||||
database (either removing jupyterhub.sqlite or some other action) while
|
||||
leaving single-user servers running. This happens most frequently when using
|
||||
DockerSpawner, because Docker's default behavior is to stop/start containers
|
||||
which resets the JupyterHub database, rather than destroying and recreating
|
||||
the container every time. This means that the same API token is used by the
|
||||
server for its whole life, until the container is rebuilt.
|
||||
|
||||
The fix for this Docker case is to remove any Docker containers seeing this
|
||||
issue (typically all containers created before a certain point in time):
|
||||
|
||||
docker rm -f jupyter-name
|
||||
|
||||
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
|
||||
your server again.
|
||||
|
||||
|
||||
## How do I...?
|
||||
|
||||
### Use a chained SSL certificate
|
||||
|
||||
Some certificate providers, i.e. Entrust, may provide you with a chained
|
||||
certificate that contains multiple files. If you are using a chained
|
||||
certificate you will need to concatenate the individual files by appending the
|
||||
chain cert and root cert to your host cert:
|
||||
|
||||
cat your_host.crt chain.crt root.crt > your_host-chained.crt
|
||||
|
||||
You would then set in your `jupyterhub_config.py` file the `ssl_key` and
|
||||
`ssl_cert` as follows:
|
||||
|
||||
c.JupyterHub.ssl_cert = your_host-chained.crt
|
||||
c.JupyterHub.ssl_key = your_host.key
|
||||
|
||||
|
||||
#### Example
|
||||
|
||||
Your certificate provider gives you the following files: `example_host.crt`,
|
||||
`Entrust_L1Kroot.txt` and `Entrust_Root.txt`.
|
||||
|
||||
Concatenate the files appending the chain cert and root cert to your host cert:
|
||||
|
||||
cat example_host.crt Entrust_L1Kroot.txt Entrust_Root.txt > example_host-chained.crt
|
||||
|
||||
You would then use the `example_host-chained.crt` as the value for
|
||||
JupyterHub's `ssl_cert`. You may pass this value as a command line option
|
||||
when starting JupyterHub or more conveniently set the `ssl_cert` variable in
|
||||
JupyterHub's configuration file, `jupyterhub_config.py`. In `jupyterhub_config.py`,
|
||||
set:
|
||||
|
||||
c.JupyterHub.ssl_cert = /path/to/example_host-chained.crt
|
||||
c.JupyterHub.ssl_key = /path/to/example_host.key
|
||||
|
||||
where `ssl_cert` is example-chained.crt and ssl_key to your private key.
|
||||
|
||||
Then restart JupyterHub.
|
||||
|
||||
See also [JupyterHub SSL encryption](getting-started.md#ssl-encryption).
|
||||
|
||||
### Install JupyterHub without a network connection
|
||||
|
||||
Both conda and pip can be used without a network connection. You can make your
|
||||
own repository (directory) of conda packages and/or wheels, and then install
|
||||
from there instead of the internet.
|
||||
|
||||
For instance, you can install JupyterHub with pip and configurable-http-proxy
|
||||
with npmbox:
|
||||
|
||||
pip wheel jupyterhub
|
||||
npmbox configurable-http-proxy
|
||||
|
||||
### I want access to the whole filesystem, but still default users to their home directory
|
||||
|
||||
Setting the following in `jupyterhub_config.py` will configure access to
|
||||
the entire filesystem and set the default to the user's home directory.
|
||||
|
||||
c.Spawner.notebook_dir = '/'
|
||||
c.Spawner.default_url = '/home/%U' # %U will be replaced with the username
|
||||
|
||||
### How do I increase the number of pySpark executors on YARN?
|
||||
|
||||
From the command line, pySpark executors can be configured using a command
|
||||
similar to this one:
|
||||
|
||||
pyspark --total-executor-cores 2 --executor-memory 1G
|
||||
|
||||
[Cloudera documentation for configuring spark on YARN applications](https://www.cloudera.com/documentation/enterprise/latest/topics/cdh_ig_running_spark_on_yarn.html#spark_on_yarn_config_apps)
|
||||
provides additional information. The [pySpark configuration documentation](https://spark.apache.org/docs/0.9.0/configuration.html)
|
||||
is also helpful for programmatic configuration examples.
|
||||
|
||||
### How do I use JupyterLab's prerelease version with JupyterHub?
|
||||
|
||||
While JupyterLab is still under active development, we have had users
|
||||
ask about how to try out JupyterLab with JupyterHub.
|
||||
|
||||
You need to install and enable the JupyterLab extension system-wide,
|
||||
then you can change the default URL to `/lab`.
|
||||
|
||||
For instance:
|
||||
|
||||
pip install jupyterlab
|
||||
jupyter serverextension enable --py jupyterlab --sys-prefix
|
||||
|
||||
The important thing is that jupyterlab is installed and enabled in the
|
||||
single-user notebook server environment. For system users, this means
|
||||
system-wide, as indicated above. For Docker containers, it means inside
|
||||
the single-user docker image, etc.
|
||||
|
||||
In `jupyterhub_config.py`, configure the Spawner to tell the single-user
|
||||
notebook servers to default to JupyterLab:
|
||||
|
||||
c.Spawner.default_url = '/lab'
|
||||
|
||||
### 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
|
||||
2. Configure whitelist to be an empty list in` jupyterhub_config.py`
|
||||
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.
|
||||
|
||||
## Troubleshooting commands
|
||||
|
||||
The following commands provide additional detail about installed packages,
|
||||
versions, and system information that may be helpful when troubleshooting
|
||||
a JupyterHub deployment. The commands are:
|
||||
|
||||
- System and deployment information
|
||||
|
||||
```bash
|
||||
jupyter troubleshooting
|
||||
```
|
||||
|
||||
- Kernel information
|
||||
|
||||
```bash
|
||||
jupyter kernelspec list
|
||||
```
|
||||
|
||||
- Debug logs when running JupyterHub
|
||||
|
||||
```bash
|
||||
jupyterhub --debug
|
||||
```
|
||||
|
||||
## Toree integration with HDFS rack awareness script
|
||||
|
||||
The Apache Toree kernel will an issue, when running with JupyterHub, if the standard HDFS
|
||||
rack awareness script is used. This will materialize in the logs as a repeated WARN:
|
||||
|
||||
```bash
|
||||
16/11/29 16:24:20 WARN ScriptBasedMapping: Exception running /etc/hadoop/conf/topology_script.py some.ip.address
|
||||
ExitCodeException exitCode=1: File "/etc/hadoop/conf/topology_script.py", line 63
|
||||
print rack
|
||||
^
|
||||
SyntaxError: Missing parentheses in call to 'print'
|
||||
|
||||
at `org.apache.hadoop.util.Shell.runCommand(Shell.java:576)`
|
||||
```
|
||||
|
||||
In order to resolve this issue, there are two potential options.
|
||||
|
||||
1. Update HDFS core-site.xml, so the parameter "net.topology.script.file.name" points to a custom
|
||||
script (e.g. /etc/hadoop/conf/custom_topology_script.py). Copy the original script and change the first line point
|
||||
to a python two installation (e.g. /usr/bin/python).
|
||||
2. In spark-env.sh add a Python 2 installation to your path (e.g. export PATH=/opt/anaconda2/bin:$PATH).
|
||||
|
||||
|
106
docs/source/upgrading.md
Normal file
106
docs/source/upgrading.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Upgrading JupyterHub and its database
|
||||
|
||||
From time to time, you may wish to upgrade JupyterHub to take advantage
|
||||
of new releases. Much of this process is automated using scripts,
|
||||
such as those generated by alembic for database upgrades. Before upgrading a
|
||||
JupyterHub deployment, it's critical to backup your data and configurations
|
||||
before shutting down the JupyterHub process and server.
|
||||
|
||||
## Databases: SQLite (default) or RDBMS (PostgreSQL, MySQL)
|
||||
|
||||
The default database for JupyterHub is a [SQLite](https://sqlite.org) database.
|
||||
We have chosen SQLite as JupyterHub's default for its lightweight simplicity
|
||||
in certain uses such as testing, small deployments and workshops.
|
||||
|
||||
When running a long term deployment or a production system, we recommend using
|
||||
a traditional RDBMS database, such as [PostgreSQL](https://www.postgresql.org)
|
||||
or [MySQL](https://www.mysql.com), that supports the SQL `ALTER TABLE`
|
||||
statement.
|
||||
|
||||
For production systems, SQLite has some disadvantages when used with JupyterHub:
|
||||
|
||||
- `upgrade-db` may not work, and you may need to start with a fresh database
|
||||
- `downgrade-db` **will not** work if you want to rollback to an earlier
|
||||
version, so backup the `jupyterhub.sqlite` file before upgrading
|
||||
|
||||
The sqlite documentation provides a helpful page about [when to use sqlite and
|
||||
where traditional RDBMS may be a better choice](https://sqlite.org/whentouse.html).
|
||||
|
||||
## The upgrade process
|
||||
|
||||
Four fundamental process steps are needed when upgrading JupyterHub and its
|
||||
database:
|
||||
|
||||
1. Backup JupyterHub database
|
||||
2. Backup JupyterHub configuration file
|
||||
3. Shutdown the Hub
|
||||
4. Upgrade JupyterHub
|
||||
5. Upgrade the database using run `jupyterhub upgrade-db`
|
||||
|
||||
Let's take a closer look at each step in the upgrade process as well as some
|
||||
additional information about JupyterHub databases.
|
||||
|
||||
### Backup JupyterHub database
|
||||
|
||||
To prevent unintended loss of data or configuration information, you should
|
||||
back up the JupyterHub database (the default SQLite database or a RDBMS
|
||||
database using PostgreSQL, MySQL, or others supported by SQLAlchemy):
|
||||
|
||||
- If using the default SQLite database, back up the `jupyterhub.sqlite`
|
||||
database.
|
||||
- If using an RDBMS database such as PostgreSQL, MySQL, or other supported by
|
||||
SQLAlchemy, back up the JupyterHub database.
|
||||
|
||||
Losing the Hub database is often not a big deal. Information that resides only
|
||||
in the Hub database includes:
|
||||
|
||||
- active login tokens (user cookies, service tokens)
|
||||
- users added via GitHub UI, instead of config files
|
||||
- info about running servers
|
||||
|
||||
If the following conditions are true, you should be fine clearing the Hub
|
||||
database and starting over:
|
||||
|
||||
- users specified in config file
|
||||
- user servers are stopped during upgrade
|
||||
- don't mind causing users to login again after upgrade
|
||||
|
||||
### Backup JupyterHub configuration file
|
||||
|
||||
Additionally, backing up your configuration file, `jupyterhub_config.py`, to
|
||||
a secure location.
|
||||
|
||||
### Shutdown JupyterHub
|
||||
|
||||
Prior to shutting down JupyterHub, you should notify the Hub users of the
|
||||
scheduled downtime. This gives users the opportunity to finish any outstanding
|
||||
work in process.
|
||||
|
||||
Next, shutdown the JupyterHub service.
|
||||
|
||||
### Upgrade JupyterHub
|
||||
|
||||
Follow directions that correspond to your package manager, `pip` or `conda`,
|
||||
for the new JupyterHub release. These directions will guide you to the
|
||||
specific command. In general, `pip install -U jupyterhub` or
|
||||
`conda upgrade jupyterhub`
|
||||
|
||||
### Upgrade JupyterHub databases
|
||||
|
||||
To run the upgrade process for JupyterHub databases, enter:
|
||||
|
||||
```
|
||||
jupyterhub upgrade-db
|
||||
```
|
||||
|
||||
## Upgrade checklist
|
||||
|
||||
1. Backup JupyterHub database:
|
||||
- `jupyterhub.sqlite` when using the default sqlite database
|
||||
- Your JupyterHub database when using an RDBMS
|
||||
2. Backup JupyterHub configuration file: `jupyterhub_config.py`
|
||||
3. Shutdown the Hub
|
||||
4. Upgrade JupyterHub
|
||||
- `pip install -U jupyterhub` when using `pip`
|
||||
- `conda upgrade jupyterhub` when using `conda`
|
||||
5. Upgrade the database using run `jupyterhub upgrade-db`
|
80
docs/source/websecurity.md
Normal file
80
docs/source/websecurity.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Web Security in JupyterHub
|
||||
|
||||
JupyterHub is designed to be a simple multi-user server for modestly sized
|
||||
groups of semi-trusted users. While the design reflects serving semi-trusted
|
||||
users, JupyterHub is not necessarily unsuitable for serving untrusted users.
|
||||
Using JupyterHub with untrusted users does mean more work and much care is
|
||||
required to secure a Hub against untrusted users, with extra caution on
|
||||
protecting users from each other as the Hub is serving untrusted users.
|
||||
|
||||
One aspect of JupyterHub's design simplicity for semi-trusted users is that
|
||||
the Hub and single-user servers are placed in a single domain, behind a
|
||||
[proxy][configurable-http-proxy]. As a result, if the Hub is serving untrusted
|
||||
users, many of the web's cross-site protections are not applied between
|
||||
single-user servers and the Hub, or between single-user servers and each
|
||||
other, since browsers see the whole thing (proxy, Hub, and single user
|
||||
servers) as a single website.
|
||||
|
||||
To protect users from each other, a user must never be able to write arbitrary
|
||||
HTML and serve it to another user on the Hub's domain. JupyterHub's
|
||||
authentication setup prevents this because only the owner of a given
|
||||
single-user server is allowed to view user-authored pages served by their
|
||||
server. To protect all users from each other, JupyterHub administrators must
|
||||
ensure that:
|
||||
|
||||
* A user does not have permission to modify their single-user server:
|
||||
- A user may not install new packages in the Python environment that runs
|
||||
their server.
|
||||
- If the PATH is used to resolve the single-user executable (instead of an
|
||||
absolute path), a user may not create new files in any PATH directory
|
||||
that precedes the directory containing jupyterhub-singleuser.
|
||||
- A user may not modify environment variables (e.g. PATH, PYTHONPATH) for
|
||||
their single-user server.
|
||||
* A user may not modify the configuration of the notebook server
|
||||
(the ~/.jupyter or JUPYTER_CONFIG_DIR directory).
|
||||
|
||||
If any additional services are run on the same domain as the Hub, the services
|
||||
must never display user-authored HTML that is neither sanitized nor sandboxed
|
||||
(e.g. IFramed) to any user that lacks authentication as the author of a file.
|
||||
|
||||
|
||||
## Mitigations
|
||||
|
||||
There are two main configuration options provided by JupyterHub to mitigate
|
||||
these issues:
|
||||
|
||||
### Subdomains
|
||||
|
||||
JupyterHub 0.5 adds the ability to run single-user servers on their own
|
||||
subdomains, which means the cross-origin protections between servers has the
|
||||
desired effect, and user servers and the Hub are protected from each other. A
|
||||
user's server will be at `username.jupyter.mydomain.com`, etc. This requires
|
||||
all user subdomains to point to the same address, which is most easily
|
||||
accomplished with wildcard DNS. Since this spreads the service across multiple
|
||||
domains, you will need wildcard SSL, as well. Unfortunately, for many
|
||||
institutional domains, wildcard DNS and SSL are not available, but if you do
|
||||
plan to serve untrusted users, enabling subdomains is highly encouraged, as it
|
||||
resolves all of the cross-site issues.
|
||||
|
||||
### Disabling user config
|
||||
|
||||
If subdomains are not available or not desirable, 0.5 also adds an option
|
||||
`Spawner.disable_user_config`, which you can set to prevent the user-owned
|
||||
configuration files from being loaded. This leaves only package installation
|
||||
and PATHs as things the admin must enforce.
|
||||
|
||||
For most Spawners, PATH is not something users can influence, but care should
|
||||
be taken to ensure that the Spawn does *not* evaluate shell configuration
|
||||
files prior to launching the server.
|
||||
|
||||
Package isolation is most easily handled by running the single-user server in
|
||||
a virtualenv with disabled system-site-packages.
|
||||
|
||||
## Extra notes
|
||||
|
||||
It is important to note that the control over the environment only affects the
|
||||
single-user server, and not the environment(s) in which the user's kernel(s)
|
||||
may run. Installing additional packages in the kernel environment does not
|
||||
pose additional risk to the web application's security.
|
||||
|
||||
[configurable-http-proxy]: https://github.com/jupyterhub/configurable-http-proxy
|
41
examples/cull-idle/README.md
Normal file
41
examples/cull-idle/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# `cull-idle` Example
|
||||
|
||||
The `cull_idle_servers.py` file provides a script to cull and shut down idle
|
||||
single-user notebook servers. This script is used when `cull-idle` is run as
|
||||
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': 'python cull_idle_servers.py --timeout=3600'.split(),
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
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`
|
||||
python cull_idle_servers.py [--timeout=900] [--url=http://127.0.0.1:8081/hub/api]
|
||||
```
|
@@ -9,10 +9,21 @@ so cull timeout should be greater than the sum of:
|
||||
- single-user websocket ping interval (default: 30s)
|
||||
- JupyterHub.last_activity_interval (default: 5 minutes)
|
||||
|
||||
Generate an API token and store it in `JPY_API_TOKEN`:
|
||||
You can run this as a service managed by JupyterHub with this in your config::
|
||||
|
||||
export JPY_API_TOKEN=`jupyterhub token`
|
||||
python cull_idle_servers.py [--timeout=900] [--url=http://127.0.0.1:8081/hub]
|
||||
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
'name': 'cull-idle',
|
||||
'admin': True,
|
||||
'command': 'python cull_idle_servers.py --timeout=3600'.split(),
|
||||
}
|
||||
]
|
||||
|
||||
Or run it manually by generating an API token and storing it in `JUPYTERHUB_API_TOKEN`:
|
||||
|
||||
export JUPYTERHUB_API_TOKEN=`jupyterhub token`
|
||||
python cull_idle_servers.py [--timeout=900] [--url=http://127.0.0.1:8081/hub/api]
|
||||
"""
|
||||
|
||||
import datetime
|
||||
@@ -34,7 +45,7 @@ def cull_idle(url, api_token, timeout):
|
||||
auth_header = {
|
||||
'Authorization': 'token %s' % api_token
|
||||
}
|
||||
req = HTTPRequest(url=url + '/api/users',
|
||||
req = HTTPRequest(url=url + '/users',
|
||||
headers=auth_header,
|
||||
)
|
||||
now = datetime.datetime.utcnow()
|
||||
@@ -47,7 +58,7 @@ def cull_idle(url, api_token, timeout):
|
||||
last_activity = parse_date(user['last_activity'])
|
||||
if user['server'] and last_activity < cull_limit:
|
||||
app_log.info("Culling %s (inactive since %s)", user['name'], last_activity)
|
||||
req = HTTPRequest(url=url + '/api/users/%s/server' % user['name'],
|
||||
req = HTTPRequest(url=url + '/users/%s/server' % user['name'],
|
||||
method='DELETE',
|
||||
headers=auth_header,
|
||||
)
|
||||
@@ -60,7 +71,7 @@ def cull_idle(url, api_token, timeout):
|
||||
app_log.debug("Finished culling %s", name)
|
||||
|
||||
if __name__ == '__main__':
|
||||
define('url', default='http://127.0.0.1:8081/hub', help="The JupyterHub API URL")
|
||||
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")
|
||||
|
||||
@@ -68,7 +79,7 @@ if __name__ == '__main__':
|
||||
if not options.cull_every:
|
||||
options.cull_every = options.timeout // 2
|
||||
|
||||
api_token = os.environ['JPY_API_TOKEN']
|
||||
api_token = os.environ['JUPYTERHUB_API_TOKEN']
|
||||
|
||||
loop = IOLoop.current()
|
||||
cull = lambda : cull_idle(options.url, api_token, options.timeout)
|
||||
|
8
examples/cull-idle/jupyterhub_config.py
Normal file
8
examples/cull-idle/jupyterhub_config.py
Normal file
@@ -0,0 +1,8 @@
|
||||
# run cull-idle as a service
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
'name': 'cull-idle',
|
||||
'admin': True,
|
||||
'command': 'python cull_idle_servers.py --timeout=3600'.split(),
|
||||
}
|
||||
]
|
@@ -1,4 +1,4 @@
|
||||
FROM jupyter/jupyterhub
|
||||
FROM jupyter/jupyterhub-onbuild
|
||||
|
||||
MAINTAINER Jupyter Project <jupyter@googlegroups.com>
|
||||
|
||||
|
33
examples/service-whoami-flask/README.md
Normal file
33
examples/service-whoami-flask/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Authenticating a flask service with JupyterHub
|
||||
|
||||
Uses `jupyterhub.services.HubAuth` to authenticate requests with the Hub in a [flask][] application.
|
||||
|
||||
## Run
|
||||
|
||||
1. Launch JupyterHub and the `whoami service` with
|
||||
|
||||
jupyterhub --ip=127.0.0.1
|
||||
|
||||
2. Visit http://127.0.0.1:8000/services/whoami
|
||||
|
||||
After logging in with your local-system credentials, you should see a JSON dump of your user info:
|
||||
|
||||
```json
|
||||
{
|
||||
"admin": false,
|
||||
"last_activity": "2016-05-27T14:05:18.016372",
|
||||
"name": "queequeg",
|
||||
"pending": null,
|
||||
"server": "/user/queequeg"
|
||||
}
|
||||
```
|
||||
|
||||
This relies on the Hub starting the whoami service, via config (see [jupyterhub_config.py](./jupyterhub_config.py)).
|
||||
|
||||
A similar service could be run externally, by setting the JupyterHub service environment variables:
|
||||
|
||||
JUPYTERHUB_API_TOKEN
|
||||
JUPYTERHUB_SERVICE_PREFIX
|
||||
|
||||
|
||||
[flask]: http://flask.pocoo.org
|
13
examples/service-whoami-flask/jupyterhub_config.py
Normal file
13
examples/service-whoami-flask/jupyterhub_config.py
Normal file
@@ -0,0 +1,13 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
'name': 'whoami',
|
||||
'url': 'http://127.0.0.1:10101',
|
||||
'command': ['flask', 'run', '--port=10101'],
|
||||
'environment': {
|
||||
'FLASK_APP': 'whoami-flask.py',
|
||||
}
|
||||
}
|
||||
]
|
4
examples/service-whoami-flask/launch.sh
Normal file
4
examples/service-whoami-flask/launch.sh
Normal file
@@ -0,0 +1,4 @@
|
||||
export CONFIGPROXY_AUTH_TOKEN=`openssl rand -hex 32`
|
||||
|
||||
# start JupyterHub
|
||||
jupyterhub --ip=127.0.0.1
|
50
examples/service-whoami-flask/whoami-flask.py
Normal file
50
examples/service-whoami-flask/whoami-flask.py
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
whoami service authentication with the Hub
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
import json
|
||||
import os
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import Flask, redirect, request, Response
|
||||
|
||||
from jupyterhub.services.auth import HubAuth
|
||||
|
||||
|
||||
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
|
||||
|
||||
auth = HubAuth(
|
||||
api_token=os.environ['JUPYTERHUB_API_TOKEN'],
|
||||
cookie_cache_max_age=60,
|
||||
)
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
def authenticated(f):
|
||||
"""Decorator for authenticating with the Hub"""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
cookie = request.cookies.get(auth.cookie_name)
|
||||
if cookie:
|
||||
user = auth.user_for_cookie(cookie)
|
||||
else:
|
||||
user = None
|
||||
if user:
|
||||
return f(user, *args, **kwargs)
|
||||
else:
|
||||
# redirect to login url on failed auth
|
||||
return redirect(auth.login_url + '?next=%s' % quote(request.path))
|
||||
return decorated
|
||||
|
||||
|
||||
@app.route(prefix + '/')
|
||||
@authenticated
|
||||
def whoami(user):
|
||||
return Response(
|
||||
json.dumps(user, indent=1, sort_keys=True),
|
||||
mimetype='application/json',
|
||||
)
|
||||
|
BIN
examples/service-whoami-flask/whoami.png
Normal file
BIN
examples/service-whoami-flask/whoami.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 35 KiB |
42
examples/service-whoami-flask/whoami.py
Normal file
42
examples/service-whoami-flask/whoami.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""An example service authenticating with the Hub.
|
||||
|
||||
This example service serves `/services/whoami/`,
|
||||
authenticated with the Hub,
|
||||
showing the user their own info.
|
||||
"""
|
||||
from getpass import getuser
|
||||
import json
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from tornado.ioloop import IOLoop
|
||||
from tornado.httpserver import HTTPServer
|
||||
from tornado.web import RequestHandler, Application, authenticated
|
||||
|
||||
from jupyterhub.services.auth import HubAuthenticated
|
||||
|
||||
|
||||
class WhoAmIHandler(HubAuthenticated, RequestHandler):
|
||||
hub_users = {getuser()} # the users allowed to access this service
|
||||
|
||||
@authenticated
|
||||
def get(self):
|
||||
user_model = self.get_current_user()
|
||||
self.set_header('content-type', 'application/json')
|
||||
self.write(json.dumps(user_model, indent=1, sort_keys=True))
|
||||
|
||||
def main():
|
||||
app = Application([
|
||||
(os.environ['JUPYTERHUB_SERVICE_PREFIX'] + '/?', WhoAmIHandler),
|
||||
(r'.*', WhoAmIHandler),
|
||||
], login_url='/hub/login')
|
||||
|
||||
http_server = HTTPServer(app)
|
||||
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
|
||||
|
||||
http_server.listen(url.port, url.hostname)
|
||||
|
||||
IOLoop.current().start()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
32
examples/service-whoami/README.md
Normal file
32
examples/service-whoami/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Authenticating a service with JupyterHub
|
||||
|
||||
Uses `jupyterhub.services.HubAuthenticated` to authenticate requests with the Hub.
|
||||
|
||||
## Run
|
||||
|
||||
1. Launch JupyterHub and the `whoami service` with
|
||||
|
||||
jupyterhub --ip=127.0.0.1
|
||||
|
||||
2. Visit http://127.0.0.1:8000/services/whoami
|
||||
|
||||
After logging in with your local-system credentials, you should see a JSON dump of your user info:
|
||||
|
||||
```json
|
||||
{
|
||||
"admin": false,
|
||||
"last_activity": "2016-05-27T14:05:18.016372",
|
||||
"name": "queequeg",
|
||||
"pending": null,
|
||||
"server": "/user/queequeg"
|
||||
}
|
||||
```
|
||||
|
||||
This relies on the Hub starting the whoami services, via config (see [jupyterhub_config.py](./jupyterhub_config.py)).
|
||||
|
||||
A similar service could be run externally, by setting the JupyterHub service environment variables:
|
||||
|
||||
JUPYTERHUB_API_TOKEN
|
||||
JUPYTERHUB_SERVICE_PREFIX
|
||||
|
||||
or instantiating and configuring a HubAuth object yourself, and attaching it as `self.hub_auth` in your HubAuthenticated handlers.
|
10
examples/service-whoami/jupyterhub_config.py
Normal file
10
examples/service-whoami/jupyterhub_config.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
'name': 'whoami',
|
||||
'url': 'http://127.0.0.1:10101',
|
||||
'command': [sys.executable, './whoami.py'],
|
||||
}
|
||||
]
|
BIN
examples/service-whoami/whoami.png
Normal file
BIN
examples/service-whoami/whoami.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 35 KiB |
40
examples/service-whoami/whoami.py
Normal file
40
examples/service-whoami/whoami.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""An example service authenticating with the Hub.
|
||||
|
||||
This serves `/services/whoami/`, authenticated with the Hub, showing the user their own info.
|
||||
"""
|
||||
from getpass import getuser
|
||||
import json
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from tornado.ioloop import IOLoop
|
||||
from tornado.httpserver import HTTPServer
|
||||
from tornado.web import RequestHandler, Application, authenticated
|
||||
|
||||
from jupyterhub.services.auth import HubAuthenticated
|
||||
|
||||
|
||||
class WhoAmIHandler(HubAuthenticated, RequestHandler):
|
||||
hub_users = {getuser()} # the users allowed to access me
|
||||
|
||||
@authenticated
|
||||
def get(self):
|
||||
user_model = self.get_current_user()
|
||||
self.set_header('content-type', 'application/json')
|
||||
self.write(json.dumps(user_model, indent=1, sort_keys=True))
|
||||
|
||||
def main():
|
||||
app = Application([
|
||||
(os.environ['JUPYTERHUB_SERVICE_PREFIX'] + '/?', WhoAmIHandler),
|
||||
(r'.*', WhoAmIHandler),
|
||||
], login_url='/hub/login')
|
||||
|
||||
http_server = HTTPServer(app)
|
||||
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
|
||||
|
||||
http_server.listen(url.port, url.hostname)
|
||||
|
||||
IOLoop.current().start()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Example JuptyerHub config allowing users to specify environment variables and notebook-server args
|
||||
Example JupyterHub config allowing users to specify environment variables and notebook-server args
|
||||
"""
|
||||
import shlex
|
||||
|
||||
|
66
jupyterhub/alembic.ini
Normal file
66
jupyterhub/alembic.ini
Normal file
@@ -0,0 +1,66 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
script_location = {alembic_dir}
|
||||
sqlalchemy.url = {db_url}
|
||||
|
||||
# template used to generate migration files
|
||||
# file_template = %%(rev)s_%%(slug)s
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
#truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; this defaults
|
||||
# to jupyterhub/alembic/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path
|
||||
# version_locations = %(here)s/bar %(here)s/bat jupyterhub/alembic/versions
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
1
jupyterhub/alembic/README
Normal file
1
jupyterhub/alembic/README
Normal file
@@ -0,0 +1 @@
|
||||
This is the alembic configuration for JupyterHub data base migrations.
|
70
jupyterhub/alembic/env.py
Normal file
70
jupyterhub/alembic/env.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from __future__ import with_statement
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from logging.config import fileConfig
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
target_metadata = None
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url, target_metadata=target_metadata, literal_binds=True)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section),
|
||||
prefix='sqlalchemy.',
|
||||
poolclass=pool.NullPool)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
24
jupyterhub/alembic/script.py.mako
Normal file
24
jupyterhub/alembic/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
@@ -0,0 +1,24 @@
|
||||
"""base revision for 0.5
|
||||
|
||||
Revision ID: 19c0846f6344
|
||||
Revises:
|
||||
Create Date: 2016-04-11 16:05:34.873288
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '19c0846f6344'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def upgrade():
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
25
jupyterhub/alembic/versions/af4cbdb2d13c_services.py
Normal file
25
jupyterhub/alembic/versions/af4cbdb2d13c_services.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""services
|
||||
|
||||
Revision ID: af4cbdb2d13c
|
||||
Revises: eeb276e51423
|
||||
Create Date: 2016-07-28 16:16:38.245348
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'af4cbdb2d13c'
|
||||
down_revision = 'eeb276e51423'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('api_tokens', sa.Column('service_id', sa.Integer))
|
||||
|
||||
|
||||
def downgrade():
|
||||
# sqlite cannot downgrade because of limited ALTER TABLE support (no DROP COLUMN)
|
||||
op.drop_column('api_tokens', 'service_id')
|
26
jupyterhub/alembic/versions/eeb276e51423_auth_state.py
Normal file
26
jupyterhub/alembic/versions/eeb276e51423_auth_state.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""auth_state
|
||||
|
||||
Adds auth_state column to Users table.
|
||||
|
||||
Revision ID: eeb276e51423
|
||||
Revises: 19c0846f6344
|
||||
Create Date: 2016-04-11 16:06:49.239831
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'eeb276e51423'
|
||||
down_revision = '19c0846f6344'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from jupyterhub.orm import JSONDict
|
||||
|
||||
def upgrade():
|
||||
op.add_column('users', sa.Column('auth_state', JSONDict))
|
||||
|
||||
|
||||
def downgrade():
|
||||
# sqlite cannot downgrade because of limited ALTER TABLE support (no DROP COLUMN)
|
||||
op.drop_column('users', 'auth_state')
|
@@ -1,11 +1,6 @@
|
||||
from .base import *
|
||||
from .auth import *
|
||||
from .hub import *
|
||||
from .proxy import *
|
||||
from .users import *
|
||||
|
||||
from . import auth, hub, proxy, users
|
||||
from . import auth, hub, proxy, users, groups, services
|
||||
|
||||
default_handlers = []
|
||||
for mod in (auth, hub, proxy, users):
|
||||
for mod in (auth, hub, proxy, users, groups, services):
|
||||
default_handlers.extend(mod.default_handlers)
|
||||
|
@@ -6,7 +6,7 @@
|
||||
import json
|
||||
from urllib.parse import quote
|
||||
|
||||
from tornado import web
|
||||
from tornado import web, gen
|
||||
from .. import orm
|
||||
from ..utils import token_authenticated
|
||||
from .base import APIHandler
|
||||
@@ -20,13 +20,25 @@ class TokenAPIHandler(APIHandler):
|
||||
raise web.HTTPError(404)
|
||||
self.write(json.dumps(self.user_model(self.users[orm_token.user])))
|
||||
|
||||
@gen.coroutine
|
||||
def post(self):
|
||||
if self.authenticator is not None:
|
||||
data = self.get_json_body()
|
||||
username = yield self.authenticator.authenticate(self, data)
|
||||
if username is None:
|
||||
raise web.HTTPError(403)
|
||||
user = self.find_user(username)
|
||||
api_token = user.new_api_token()
|
||||
self.write(json.dumps({"Authentication":api_token}))
|
||||
else:
|
||||
raise web.HTTPError(404)
|
||||
|
||||
class CookieAPIHandler(APIHandler):
|
||||
@token_authenticated
|
||||
def get(self, cookie_name, cookie_value=None):
|
||||
cookie_name = quote(cookie_name, safe='')
|
||||
if cookie_value is None:
|
||||
self.log.warn("Cookie values in request body is deprecated, use `/cookie_name/cookie_value`")
|
||||
self.log.warning("Cookie values in request body is deprecated, use `/cookie_name/cookie_value`")
|
||||
cookie_value = self.request.body
|
||||
else:
|
||||
cookie_value = cookie_value.encode('utf8')
|
||||
@@ -39,4 +51,5 @@ class CookieAPIHandler(APIHandler):
|
||||
default_handlers = [
|
||||
(r"/api/authorizations/cookie/([^/]+)(?:/([^/]+))?", CookieAPIHandler),
|
||||
(r"/api/authorizations/token/([^/]+)", TokenAPIHandler),
|
||||
(r"/api/authorizations/token", TokenAPIHandler),
|
||||
]
|
||||
|
@@ -26,25 +26,29 @@ class APIHandler(BaseHandler):
|
||||
# If no header is provided, assume it comes from a script/curl.
|
||||
# We are only concerned with cross-site browser stuff here.
|
||||
if not host:
|
||||
self.log.warn("Blocking API request with no host")
|
||||
self.log.warning("Blocking API request with no host")
|
||||
return False
|
||||
if not referer:
|
||||
self.log.warn("Blocking API request with no referer")
|
||||
self.log.warning("Blocking API request with no referer")
|
||||
return False
|
||||
|
||||
host_path = url_path_join(host, self.hub.server.base_url)
|
||||
referer_path = referer.split('://', 1)[-1]
|
||||
if not (referer_path + '/').startswith(host_path):
|
||||
self.log.warn("Blocking Cross Origin API request. Referer: %s, Host: %s",
|
||||
self.log.warning("Blocking Cross Origin API request. Referer: %s, Host: %s",
|
||||
referer, host_path)
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_current_user_cookie(self):
|
||||
"""Override get_user_cookie to check Referer header"""
|
||||
if not self.check_referer():
|
||||
cookie_user = super().get_current_user_cookie()
|
||||
# check referer only if there is a cookie user,
|
||||
# avoiding misleading "Blocking Cross Origin" messages
|
||||
# when there's no cookie set anyway.
|
||||
if cookie_user and not self.check_referer():
|
||||
return None
|
||||
return super().get_current_user_cookie()
|
||||
return cookie_user
|
||||
|
||||
def get_json_body(self):
|
||||
"""Return the body of the request as JSON data."""
|
||||
@@ -83,10 +87,12 @@ class APIHandler(BaseHandler):
|
||||
}))
|
||||
|
||||
def user_model(self, user):
|
||||
"""Get the JSON model for a User object"""
|
||||
model = {
|
||||
'name': user.name,
|
||||
'admin': user.admin,
|
||||
'server': user.server.base_url if user.running else None,
|
||||
'groups': [ g.name for g in user.groups ],
|
||||
'server': user.url if user.running else None,
|
||||
'pending': None,
|
||||
'last_activity': user.last_activity.isoformat(),
|
||||
}
|
||||
@@ -95,24 +101,57 @@ class APIHandler(BaseHandler):
|
||||
elif user.stop_pending:
|
||||
model['pending'] = 'stop'
|
||||
return model
|
||||
|
||||
_model_types = {
|
||||
|
||||
def group_model(self, group):
|
||||
"""Get the JSON model for a Group object"""
|
||||
return {
|
||||
'name': group.name,
|
||||
'users': [ u.name for u in group.users ]
|
||||
}
|
||||
|
||||
_user_model_types = {
|
||||
'name': str,
|
||||
'admin': bool,
|
||||
'groups': list,
|
||||
}
|
||||
|
||||
def _check_user_model(self, model):
|
||||
|
||||
_group_model_types = {
|
||||
'name': str,
|
||||
'users': list,
|
||||
}
|
||||
|
||||
def _check_model(self, model, model_types, name):
|
||||
"""Check a model provided by a REST API request
|
||||
|
||||
Args:
|
||||
model (dict): user-provided model
|
||||
model_types (dict): dict of key:type used to validate types and keys
|
||||
name (str): name of the model, used in error messages
|
||||
"""
|
||||
if not isinstance(model, dict):
|
||||
raise web.HTTPError(400, "Invalid JSON data: %r" % model)
|
||||
if not set(model).issubset(set(self._model_types)):
|
||||
if not set(model).issubset(set(model_types)):
|
||||
raise web.HTTPError(400, "Invalid JSON keys: %r" % model)
|
||||
for key, value in model.items():
|
||||
if not isinstance(value, self._model_types[key]):
|
||||
raise web.HTTPError(400, "user.%s must be %s, not: %r" % (
|
||||
key, self._model_types[key], type(value)
|
||||
if not isinstance(value, model_types[key]):
|
||||
raise web.HTTPError(400, "%s.%s must be %s, not: %r" % (
|
||||
name, key, model_types[key], type(value)
|
||||
))
|
||||
|
||||
def _check_user_model(self, model):
|
||||
"""Check a request-provided user model from a REST API"""
|
||||
self._check_model(model, self._user_model_types, 'user')
|
||||
for username in model.get('users', []):
|
||||
if not isinstance(username, str):
|
||||
raise web.HTTPError(400, ("usernames must be str, not %r", type(username)))
|
||||
|
||||
def _check_group_model(self, model):
|
||||
"""Check a request-provided group model from a REST API"""
|
||||
self._check_model(model, self._group_model_types, 'group')
|
||||
for groupname in model.get('groups', []):
|
||||
if not isinstance(groupname, str):
|
||||
raise web.HTTPError(400, ("group names must be str, not %r", type(groupname)))
|
||||
|
||||
def options(self, *args, **kwargs):
|
||||
self.set_header('Access-Control-Allow-Headers', 'accept, content-type')
|
||||
self.finish()
|
||||
|
136
jupyterhub/apihandlers/groups.py
Normal file
136
jupyterhub/apihandlers/groups.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Group handlers"""
|
||||
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
import json
|
||||
|
||||
from tornado import gen, web
|
||||
|
||||
from .. import orm
|
||||
from ..utils import admin_only
|
||||
from .base import APIHandler
|
||||
|
||||
|
||||
class _GroupAPIHandler(APIHandler):
|
||||
def _usernames_to_users(self, usernames):
|
||||
"""Turn a list of usernames into user objects"""
|
||||
users = []
|
||||
for username in usernames:
|
||||
username = self.authenticator.normalize_username(username)
|
||||
user = self.find_user(username)
|
||||
if user is None:
|
||||
raise web.HTTPError(400, "No such user: %s" % username)
|
||||
users.append(user.orm_user)
|
||||
return users
|
||||
|
||||
def find_group(self, name):
|
||||
"""Find and return a group by name.
|
||||
|
||||
Raise 404 if not found.
|
||||
"""
|
||||
group = orm.Group.find(self.db, name=name)
|
||||
if group is None:
|
||||
raise web.HTTPError(404, "No such group: %s", name)
|
||||
return group
|
||||
|
||||
class GroupListAPIHandler(_GroupAPIHandler):
|
||||
@admin_only
|
||||
def get(self):
|
||||
"""List groups"""
|
||||
data = [ self.group_model(g) for g in self.db.query(orm.Group) ]
|
||||
self.write(json.dumps(data))
|
||||
|
||||
|
||||
class GroupAPIHandler(_GroupAPIHandler):
|
||||
"""View and modify groups by name"""
|
||||
|
||||
@admin_only
|
||||
def get(self, name):
|
||||
group = self.find_group(name)
|
||||
self.write(json.dumps(self.group_model(group)))
|
||||
|
||||
@admin_only
|
||||
@gen.coroutine
|
||||
def post(self, name):
|
||||
"""POST creates a group by name"""
|
||||
model = self.get_json_body()
|
||||
if model is None:
|
||||
model = {}
|
||||
else:
|
||||
self._check_group_model(model)
|
||||
|
||||
existing = orm.Group.find(self.db, name=name)
|
||||
if existing is not None:
|
||||
raise web.HTTPError(400, "Group %s already exists" % name)
|
||||
|
||||
usernames = model.get('users', [])
|
||||
# check that users exist
|
||||
users = self._usernames_to_users(usernames)
|
||||
|
||||
# create the group
|
||||
self.log.info("Creating new group %s with %i users",
|
||||
name, len(users),
|
||||
)
|
||||
self.log.debug("Users: %s", usernames)
|
||||
group = orm.Group(name=name, users=users)
|
||||
self.db.add(group)
|
||||
self.db.commit()
|
||||
self.write(json.dumps(self.group_model(group)))
|
||||
self.set_status(201)
|
||||
|
||||
@admin_only
|
||||
def delete(self, name):
|
||||
"""Delete a group by name"""
|
||||
group = self.find_group(name)
|
||||
self.log.info("Deleting group %s", name)
|
||||
self.db.delete(group)
|
||||
self.db.commit()
|
||||
self.set_status(204)
|
||||
|
||||
|
||||
class GroupUsersAPIHandler(_GroupAPIHandler):
|
||||
"""Modify a group's user list"""
|
||||
@admin_only
|
||||
def post(self, name):
|
||||
"""POST adds users to a group"""
|
||||
group = self.find_group(name)
|
||||
data = self.get_json_body()
|
||||
self._check_group_model(data)
|
||||
if 'users' not in data:
|
||||
raise web.HTTPError(400, "Must specify users to add")
|
||||
self.log.info("Adding %i users to group %s", len(data['users']), name)
|
||||
self.log.debug("Adding: %s", data['users'])
|
||||
for user in self._usernames_to_users(data['users']):
|
||||
if user not in group.users:
|
||||
group.users.append(user)
|
||||
else:
|
||||
self.log.warning("User %s already in group %s", user.name, name)
|
||||
self.db.commit()
|
||||
self.write(json.dumps(self.group_model(group)))
|
||||
|
||||
@gen.coroutine
|
||||
@admin_only
|
||||
def delete(self, name):
|
||||
"""DELETE removes users from a group"""
|
||||
group = self.find_group(name)
|
||||
data = self.get_json_body()
|
||||
self._check_group_model(data)
|
||||
if 'users' not in data:
|
||||
raise web.HTTPError(400, "Must specify users to delete")
|
||||
self.log.info("Removing %i users from group %s", len(data['users']), name)
|
||||
self.log.debug("Removing: %s", data['users'])
|
||||
for user in self._usernames_to_users(data['users']):
|
||||
if user in group.users:
|
||||
group.users.remove(user)
|
||||
else:
|
||||
self.log.warning("User %s already not in group %s", user.name, name)
|
||||
self.db.commit()
|
||||
self.write(json.dumps(self.group_model(group)))
|
||||
|
||||
|
||||
default_handlers = [
|
||||
(r"/api/groups", GroupListAPIHandler),
|
||||
(r"/api/groups/([^/]+)", GroupAPIHandler),
|
||||
(r"/api/groups/([^/]+)/users", GroupUsersAPIHandler),
|
||||
]
|
@@ -4,12 +4,15 @@
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
from tornado import web
|
||||
from tornado.ioloop import IOLoop
|
||||
|
||||
from ..utils import admin_only
|
||||
from .base import APIHandler
|
||||
from ..version import __version__
|
||||
|
||||
|
||||
class ShutdownAPIHandler(APIHandler):
|
||||
|
||||
@@ -49,6 +52,56 @@ class ShutdownAPIHandler(APIHandler):
|
||||
loop.add_callback(loop.stop)
|
||||
|
||||
|
||||
class RootAPIHandler(APIHandler):
|
||||
|
||||
def get(self):
|
||||
"""GET /api/ returns info about the Hub and its API.
|
||||
|
||||
It is not an authenticated endpoint.
|
||||
|
||||
For now, it just returns the version of JupyterHub itself.
|
||||
"""
|
||||
data = {
|
||||
'version': __version__,
|
||||
}
|
||||
self.finish(json.dumps(data))
|
||||
|
||||
|
||||
class InfoAPIHandler(APIHandler):
|
||||
|
||||
@admin_only
|
||||
def get(self):
|
||||
"""GET /api/info returns detailed info about the Hub and its API.
|
||||
|
||||
It is not an authenticated endpoint.
|
||||
|
||||
For now, it just returns the version of JupyterHub itself.
|
||||
"""
|
||||
def _class_info(typ):
|
||||
"""info about a class (Spawner or Authenticator)"""
|
||||
info = {
|
||||
'class': '{mod}.{name}'.format(mod=typ.__module__, name=typ.__name__),
|
||||
}
|
||||
pkg = typ.__module__.split('.')[0]
|
||||
try:
|
||||
version = sys.modules[pkg].__version__
|
||||
except (KeyError, AttributeError):
|
||||
version = 'unknown'
|
||||
info['version'] = version
|
||||
return info
|
||||
|
||||
data = {
|
||||
'version': __version__,
|
||||
'python': sys.version,
|
||||
'sys_executable': sys.executable,
|
||||
'spawner': _class_info(self.settings['spawner_class']),
|
||||
'authenticator': _class_info(self.authenticator.__class__),
|
||||
}
|
||||
self.finish(json.dumps(data))
|
||||
|
||||
|
||||
default_handlers = [
|
||||
(r"/api/shutdown", ShutdownAPIHandler),
|
||||
(r"/api/?", RootAPIHandler),
|
||||
(r"/api/info", InfoAPIHandler),
|
||||
]
|
||||
|
@@ -28,7 +28,7 @@ class ProxyAPIHandler(APIHandler):
|
||||
@gen.coroutine
|
||||
def post(self):
|
||||
"""POST checks the proxy to ensure"""
|
||||
yield self.proxy.check_routes()
|
||||
yield self.proxy.check_routes(self.users, self.services)
|
||||
|
||||
|
||||
@admin_only
|
||||
@@ -59,7 +59,7 @@ class ProxyAPIHandler(APIHandler):
|
||||
self.proxy.auth_token = model['auth_token']
|
||||
self.db.commit()
|
||||
self.log.info("Updated proxy at %s", server.bind_url)
|
||||
yield self.proxy.check_routes()
|
||||
yield self.proxy.check_routes(self.users, self.services)
|
||||
|
||||
|
||||
|
||||
|
64
jupyterhub/apihandlers/services.py
Normal file
64
jupyterhub/apihandlers/services.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Service handlers
|
||||
|
||||
Currently GET-only, no actions can be taken to modify services.
|
||||
"""
|
||||
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
import json
|
||||
|
||||
from tornado import web
|
||||
|
||||
from .. import orm
|
||||
from ..utils import admin_only
|
||||
from .base import APIHandler
|
||||
|
||||
def service_model(service):
|
||||
"""Produce the model for a service"""
|
||||
return {
|
||||
'name': service.name,
|
||||
'admin': service.admin,
|
||||
'url': service.url,
|
||||
'prefix': service.server.base_url if service.server else '',
|
||||
'command': service.command,
|
||||
'pid': service.proc.pid if service.proc else 0,
|
||||
}
|
||||
|
||||
class ServiceListAPIHandler(APIHandler):
|
||||
@admin_only
|
||||
def get(self):
|
||||
data = {name: service_model(service) for name, service in self.services.items()}
|
||||
self.write(json.dumps(data))
|
||||
|
||||
|
||||
def admin_or_self(method):
|
||||
"""Decorator for restricting access to either the target service or admin"""
|
||||
def decorated_method(self, name):
|
||||
current = self.get_current_user()
|
||||
if current is None:
|
||||
raise web.HTTPError(403)
|
||||
if not current.admin:
|
||||
# not admin, maybe self
|
||||
if not isinstance(current, orm.Service):
|
||||
raise web.HTTPError(403)
|
||||
if current.name != name:
|
||||
raise web.HTTPError(403)
|
||||
# raise 404 if not found
|
||||
if name not in self.services:
|
||||
raise web.HTTPError(404)
|
||||
return method(self, name)
|
||||
return decorated_method
|
||||
|
||||
class ServiceAPIHandler(APIHandler):
|
||||
|
||||
@admin_or_self
|
||||
def get(self, name):
|
||||
service = self.services[name]
|
||||
self.write(json.dumps(service_model(service)))
|
||||
|
||||
|
||||
default_handlers = [
|
||||
(r"/api/services", ServiceListAPIHandler),
|
||||
(r"/api/services/([^/]+)", ServiceAPIHandler),
|
||||
]
|
@@ -41,7 +41,7 @@ class UserListAPIHandler(APIHandler):
|
||||
continue
|
||||
user = self.find_user(name)
|
||||
if user is not None:
|
||||
self.log.warn("User %s already exists" % name)
|
||||
self.log.warning("User %s already exists" % name)
|
||||
else:
|
||||
to_create.append(name)
|
||||
|
||||
@@ -161,8 +161,9 @@ class UserServerAPIHandler(APIHandler):
|
||||
@admin_or_self
|
||||
def post(self, name):
|
||||
user = self.find_user(name)
|
||||
if user.spawner:
|
||||
state = yield user.spawner.poll()
|
||||
if user.running:
|
||||
# include notify, so that a server that died is noticed immediately
|
||||
state = yield user.spawner.poll_and_notify()
|
||||
if state is None:
|
||||
raise web.HTTPError(400, "%s's server is already running" % name)
|
||||
|
||||
@@ -180,7 +181,8 @@ class UserServerAPIHandler(APIHandler):
|
||||
return
|
||||
if not user.running:
|
||||
raise web.HTTPError(400, "%s's server is not running" % name)
|
||||
status = yield user.spawner.poll()
|
||||
# include notify, so that a server that died is noticed immediately
|
||||
status = yield user.spawner.poll_and_notify()
|
||||
if status is not None:
|
||||
raise web.HTTPError(400, "%s's server is not running" % name)
|
||||
yield self.stop_single_user(user)
|
||||
@@ -195,7 +197,7 @@ class UserAdminAccessAPIHandler(APIHandler):
|
||||
@admin_only
|
||||
def post(self, name):
|
||||
current = self.get_current_user()
|
||||
self.log.warn("Admin user %s has requested access to %s's server",
|
||||
self.log.warning("Admin user %s has requested access to %s's server",
|
||||
current.name, name,
|
||||
)
|
||||
if not self.settings.get('admin_access', False):
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -15,93 +15,134 @@ from tornado import gen
|
||||
import pamela
|
||||
|
||||
from traitlets.config import LoggingConfigurable
|
||||
from traitlets import Bool, Set, Unicode, Dict, Any
|
||||
from traitlets import Bool, Set, Unicode, Dict, Any, default, observe
|
||||
|
||||
from .handlers.login import LoginHandler
|
||||
from .utils import url_path_join
|
||||
from .traitlets import Command
|
||||
|
||||
class Authenticator(LoggingConfigurable):
|
||||
"""A class for authentication.
|
||||
|
||||
The primary API is one method, `authenticate`, a tornado coroutine
|
||||
for authenticating users.
|
||||
"""
|
||||
|
||||
db = Any()
|
||||
admin_users = Set(config=True,
|
||||
help="""set of usernames of admin users
|
||||
|
||||
If unspecified, only the user that launches the server will be admin.
|
||||
class Authenticator(LoggingConfigurable):
|
||||
"""Base class for implementing an authentication provider for JupyterHub"""
|
||||
|
||||
db = Any()
|
||||
|
||||
admin_users = Set(
|
||||
help="""
|
||||
Set of users that will have admin rights on this JupyterHub.
|
||||
|
||||
Admin users have extra privilages:
|
||||
- Use the admin panel to see list of users logged in
|
||||
- Add / remove users in some authenticators
|
||||
- Restart / halt the hub
|
||||
- Start / stop users' single-user servers
|
||||
- Can access each individual users' single-user server (if configured)
|
||||
|
||||
Admin access should be treated the same way root access is.
|
||||
|
||||
Defaults to an empty set, in which case no user has admin access.
|
||||
"""
|
||||
).tag(config=True)
|
||||
|
||||
whitelist = Set(
|
||||
help="""
|
||||
Whitelist of usernames that are allowed to log in.
|
||||
|
||||
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
|
||||
authenticator has in place.
|
||||
|
||||
If empty, does not perform any additional restriction.
|
||||
"""
|
||||
).tag(config=True)
|
||||
|
||||
@observe('whitelist')
|
||||
def _check_whitelist(self, change):
|
||||
short_names = [name for name in change['new'] if len(name) <= 1]
|
||||
if short_names:
|
||||
sorted_names = sorted(short_names)
|
||||
single = ''.join(sorted_names)
|
||||
string_set_typo = "set('%s')" % single
|
||||
self.log.warning("whitelist contains single-character names: %s; did you mean set([%r]) instead of %s?",
|
||||
sorted_names[:8], single, string_set_typo,
|
||||
)
|
||||
|
||||
custom_html = Unicode(
|
||||
help="""
|
||||
HTML form to be overridden by authenticators if they want a custom authentication form.
|
||||
|
||||
Defaults to an empty string, which shows the default username/password form.
|
||||
"""
|
||||
)
|
||||
whitelist = Set(config=True,
|
||||
help="""Username whitelist.
|
||||
|
||||
Use this to restrict which users can login.
|
||||
If empty, allow any user to attempt login.
|
||||
|
||||
login_service = Unicode(
|
||||
help="""
|
||||
Name of the login service that this authenticator is providing using to authenticate users.
|
||||
|
||||
Example: GitHub, MediaWiki, Google, etc.
|
||||
|
||||
Setting this value replaces the login form with a "Login with <login_service>" button.
|
||||
|
||||
Any authenticator that redirects to an external service (e.g. using OAuth) should set this.
|
||||
"""
|
||||
)
|
||||
custom_html = Unicode('',
|
||||
help="""HTML login form for custom handlers.
|
||||
Override in form-based custom authenticators
|
||||
that don't use username+password,
|
||||
or need custom branding.
|
||||
|
||||
username_pattern = Unicode(
|
||||
help="""
|
||||
Regular expression pattern that all valid usernames must match.
|
||||
|
||||
If a username does not match the pattern specified here, authentication will not be attempted.
|
||||
|
||||
If not set, allow any username.
|
||||
"""
|
||||
)
|
||||
login_service = Unicode('',
|
||||
help="""Name of the login service for external
|
||||
login services (e.g. 'GitHub').
|
||||
"""
|
||||
)
|
||||
|
||||
username_pattern = Unicode(config=True,
|
||||
help="""Regular expression pattern for validating usernames.
|
||||
|
||||
If not defined: allow any username.
|
||||
"""
|
||||
)
|
||||
def _username_pattern_changed(self, name, old, new):
|
||||
if not new:
|
||||
).tag(config=True)
|
||||
|
||||
@observe('username_pattern')
|
||||
def _username_pattern_changed(self, change):
|
||||
if not change['new']:
|
||||
self.username_regex = None
|
||||
self.username_regex = re.compile(new)
|
||||
|
||||
username_regex = Any()
|
||||
|
||||
self.username_regex = re.compile(change['new'])
|
||||
|
||||
username_regex = Any(
|
||||
help="""
|
||||
Compiled regex kept in sync with `username_pattern`
|
||||
"""
|
||||
)
|
||||
|
||||
def validate_username(self, username):
|
||||
"""Validate a (normalized) username.
|
||||
|
||||
"""Validate a normalized username
|
||||
|
||||
Return True if username is valid, False otherwise.
|
||||
"""
|
||||
if not self.username_regex:
|
||||
return True
|
||||
return bool(self.username_regex.match(username))
|
||||
|
||||
username_map = Dict(config=True,
|
||||
|
||||
username_map = Dict(
|
||||
help="""Dictionary mapping authenticator usernames to JupyterHub users.
|
||||
|
||||
Can be used to map OAuth service names to local users, for instance.
|
||||
|
||||
Used in normalize_username.
|
||||
|
||||
Primarily used to normalize OAuth user names to local users.
|
||||
"""
|
||||
)
|
||||
|
||||
).tag(config=True)
|
||||
|
||||
def normalize_username(self, username):
|
||||
"""Normalize a username.
|
||||
|
||||
Override in subclasses if usernames should have some normalization.
|
||||
Default: cast to lowercase, lookup in username_map.
|
||||
"""Normalize the given username and return it
|
||||
|
||||
Override in subclasses if usernames need different normalization rules.
|
||||
|
||||
The default attempts to lowercase the username and apply `username_map` if it is
|
||||
set.
|
||||
"""
|
||||
username = username.lower()
|
||||
username = self.username_map.get(username, username)
|
||||
return username
|
||||
|
||||
|
||||
def check_whitelist(self, username):
|
||||
"""Check a username against our whitelist.
|
||||
|
||||
"""Check if a username is allowed to authenticate based on whitelist configuration
|
||||
|
||||
Return True if username is allowed, False otherwise.
|
||||
No whitelist means any username should be allowed.
|
||||
|
||||
No whitelist means any username is allowed.
|
||||
|
||||
Names are normalized *before* being checked against the whitelist.
|
||||
"""
|
||||
if not self.whitelist:
|
||||
@@ -111,18 +152,21 @@ class Authenticator(LoggingConfigurable):
|
||||
|
||||
@gen.coroutine
|
||||
def get_authenticated_user(self, handler, data):
|
||||
"""This is the outer API for authenticating a user.
|
||||
|
||||
"""Authenticate the user who is attempting to log in
|
||||
|
||||
Returns normalized username if successful, None otherwise.
|
||||
|
||||
This calls `authenticate`, which should be overridden in subclasses,
|
||||
normalizes the username if any normalization should be done,
|
||||
and then validates the name in the whitelist.
|
||||
|
||||
|
||||
This is the outer API for authenticating a user.
|
||||
Subclasses should not need to override this method.
|
||||
|
||||
The various stages can be overridden separately:
|
||||
|
||||
- authenticate turns formdata into a username
|
||||
- normalize_username normalizes the username
|
||||
- check_whitelist checks against the user whitelist
|
||||
- `authenticate` turns formdata into a username
|
||||
- `normalize_username` normalizes the username
|
||||
- `check_whitelist` checks against the user whitelist
|
||||
"""
|
||||
username = yield self.authenticate(handler, data)
|
||||
if username is None:
|
||||
@@ -131,52 +175,62 @@ class Authenticator(LoggingConfigurable):
|
||||
if not self.validate_username(username):
|
||||
self.log.warning("Disallowing invalid username %r.", username)
|
||||
return
|
||||
if self.check_whitelist(username):
|
||||
|
||||
whitelist_pass = yield gen.maybe_future(self.check_whitelist(username))
|
||||
if whitelist_pass:
|
||||
return username
|
||||
else:
|
||||
self.log.warning("User %r not in whitelist.", username)
|
||||
return
|
||||
|
||||
|
||||
@gen.coroutine
|
||||
def authenticate(self, handler, data):
|
||||
"""Authenticate a user with login form data.
|
||||
|
||||
"""Authenticate a user with login form data
|
||||
|
||||
This must be a tornado gen.coroutine.
|
||||
It must return the username on successful authentication,
|
||||
and return None on failed authentication.
|
||||
|
||||
|
||||
Checking the whitelist is handled separately by the caller.
|
||||
|
||||
Args:
|
||||
handler (tornado.web.RequestHandler): the current request handler
|
||||
data (dict): The formdata of the login form.
|
||||
The default form has 'username' and 'password' fields.
|
||||
Return:
|
||||
str: the username of the authenticated user
|
||||
None: Authentication failed
|
||||
Returns:
|
||||
username (str or None): The username of the authenticated user,
|
||||
or None if Authentication failed
|
||||
"""
|
||||
|
||||
def pre_spawn_start(self, user, spawner):
|
||||
"""Hook called before spawning a user's server.
|
||||
|
||||
"""Hook called before spawning a user's server
|
||||
|
||||
Can be used to do auth-related startup, e.g. opening PAM sessions.
|
||||
"""
|
||||
|
||||
|
||||
def post_spawn_stop(self, user, spawner):
|
||||
"""Hook called after stopping a user container.
|
||||
|
||||
"""Hook called after stopping a user container
|
||||
|
||||
Can be used to do auth-related cleanup, e.g. closing PAM sessions.
|
||||
"""
|
||||
|
||||
|
||||
def add_user(self, user):
|
||||
"""Add a new user
|
||||
|
||||
"""Hook called when a user is added to JupyterHub
|
||||
|
||||
This is called:
|
||||
- When a user first authenticates
|
||||
- When the hub restarts, for all users.
|
||||
|
||||
This method may be a coroutine.
|
||||
|
||||
By default, this just adds the user to the whitelist.
|
||||
|
||||
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.
|
||||
|
||||
Note that this should be idempotent, since it is called whenever the hub restarts
|
||||
for all users.
|
||||
|
||||
Args:
|
||||
user (User): The User wrapper object
|
||||
"""
|
||||
@@ -184,95 +238,108 @@ class Authenticator(LoggingConfigurable):
|
||||
raise ValueError("Invalid username: %s" % user.name)
|
||||
if self.whitelist:
|
||||
self.whitelist.add(user.name)
|
||||
|
||||
|
||||
def delete_user(self, user):
|
||||
"""Triggered when a user is deleted.
|
||||
|
||||
"""Hook called when a user is deleted
|
||||
|
||||
Removes the user from the whitelist.
|
||||
Subclasses should call super to ensure the whitelist is updated.
|
||||
|
||||
|
||||
Args:
|
||||
user (User): The User wrapper object
|
||||
"""
|
||||
self.whitelist.discard(user.name)
|
||||
|
||||
|
||||
def login_url(self, base_url):
|
||||
"""Override to register a custom login handler
|
||||
|
||||
Generally used in combination with get_handlers.
|
||||
|
||||
"""Override this when registering a custom login handler
|
||||
|
||||
Generally used by authenticators that do not use simple form based authentication.
|
||||
|
||||
The subclass overriding this is responsible for making sure there is a handler
|
||||
available to handle the URL returned from this method, using the `get_handlers`
|
||||
method.
|
||||
|
||||
Args:
|
||||
base_url (str): the base URL of the Hub (e.g. /hub/)
|
||||
|
||||
|
||||
Returns:
|
||||
str: The login URL, e.g. '/hub/login'
|
||||
|
||||
"""
|
||||
return url_path_join(base_url, 'login')
|
||||
|
||||
|
||||
def logout_url(self, base_url):
|
||||
"""Override to register a custom logout handler.
|
||||
|
||||
Generally used in combination with get_handlers.
|
||||
|
||||
"""Override when registering a custom logout handler
|
||||
|
||||
The subclass overriding this is responsible for making sure there is a handler
|
||||
available to handle the URL returned from this method, using the `get_handlers`
|
||||
method.
|
||||
|
||||
Args:
|
||||
base_url (str): the base URL of the Hub (e.g. /hub/)
|
||||
|
||||
|
||||
Returns:
|
||||
str: The logout URL, e.g. '/hub/logout'
|
||||
"""
|
||||
return url_path_join(base_url, 'logout')
|
||||
|
||||
|
||||
def get_handlers(self, app):
|
||||
"""Return any custom handlers the authenticator needs to register
|
||||
|
||||
(e.g. for OAuth).
|
||||
|
||||
|
||||
Used in conjugation with `login_url` and `logout_url`.
|
||||
|
||||
Args:
|
||||
app (JupyterHub Application):
|
||||
the application object, in case it needs to be accessed for info.
|
||||
Returns:
|
||||
list: list of ``('/url', Handler)`` tuples passed to tornado.
|
||||
handlers (list):
|
||||
list of ``('/url', Handler)`` tuples passed to tornado.
|
||||
The Hub prefix is added to any URLs.
|
||||
|
||||
"""
|
||||
return [
|
||||
('/login', LoginHandler),
|
||||
]
|
||||
|
||||
|
||||
class LocalAuthenticator(Authenticator):
|
||||
"""Base class for Authenticators that work with local Linux/UNIX users
|
||||
|
||||
Checks for local users, and can attempt to create them if they exist.
|
||||
"""
|
||||
|
||||
create_system_users = Bool(False, config=True,
|
||||
help="""If a user is added that doesn't exist on the system,
|
||||
should I try to create the system user?
|
||||
|
||||
create_system_users = Bool(False,
|
||||
help="""
|
||||
If set to True, will attempt to create local system users if they do not exist already.
|
||||
|
||||
Supports Linux and BSD variants only.
|
||||
"""
|
||||
)
|
||||
add_user_cmd = Command(config=True,
|
||||
help="""The command to use for creating users as a list of strings.
|
||||
|
||||
).tag(config=True)
|
||||
|
||||
add_user_cmd = Command(
|
||||
help="""
|
||||
The command to use for creating users as a list of strings
|
||||
|
||||
For each element in the list, the string USERNAME will be replaced with
|
||||
the user's username. The username will also be appended as the final argument.
|
||||
|
||||
|
||||
For Linux, the default value is:
|
||||
|
||||
|
||||
['adduser', '-q', '--gecos', '""', '--disabled-password']
|
||||
|
||||
|
||||
To specify a custom home directory, set this to:
|
||||
|
||||
|
||||
['adduser', '-q', '--gecos', '""', '--home', '/customhome/USERNAME', '--disabled-password']
|
||||
|
||||
This will run the command:
|
||||
|
||||
adduser -q --gecos "" --home /customhome/river --disabled-password river
|
||||
|
||||
adduser -q --gecos "" --home /customhome/river --disabled-password river
|
||||
|
||||
when the user 'river' is created.
|
||||
"""
|
||||
)
|
||||
).tag(config=True)
|
||||
|
||||
@default('add_user_cmd')
|
||||
def _add_user_cmd_default(self):
|
||||
"""Guess the most likely-to-work adduser command for each platform"""
|
||||
if sys.platform == 'darwin':
|
||||
raise ValueError("I don't know how to create users on OS X")
|
||||
elif which('pw'):
|
||||
@@ -283,13 +350,20 @@ class LocalAuthenticator(Authenticator):
|
||||
return ['adduser', '-q', '--gecos', '""', '--disabled-password']
|
||||
|
||||
group_whitelist = Set(
|
||||
config=True,
|
||||
help="Automatically whitelist anyone in this group.",
|
||||
)
|
||||
help="""
|
||||
Whitelist all users from this UNIX group.
|
||||
|
||||
def _group_whitelist_changed(self, name, old, new):
|
||||
This makes the username whitelist ineffective.
|
||||
"""
|
||||
).tag(config=True)
|
||||
|
||||
@observe('group_whitelist')
|
||||
def _group_whitelist_changed(self, change):
|
||||
"""
|
||||
Log a warning if both group_whitelist and user whitelist are set.
|
||||
"""
|
||||
if self.whitelist:
|
||||
self.log.warn(
|
||||
self.log.warning(
|
||||
"Ignoring username whitelist because group whitelist supplied!"
|
||||
)
|
||||
|
||||
@@ -300,6 +374,9 @@ class LocalAuthenticator(Authenticator):
|
||||
return super().check_whitelist(username)
|
||||
|
||||
def check_group_whitelist(self, username):
|
||||
"""
|
||||
If group_whitelist is configured, check if authenticating user is part of group.
|
||||
"""
|
||||
if not self.group_whitelist:
|
||||
return False
|
||||
for grnam in self.group_whitelist:
|
||||
@@ -314,9 +391,9 @@ class LocalAuthenticator(Authenticator):
|
||||
|
||||
@gen.coroutine
|
||||
def add_user(self, user):
|
||||
"""Add a new user
|
||||
|
||||
If self.create_system_users, the user will attempt to be created.
|
||||
"""Hook called whenever a new user is added
|
||||
|
||||
If self.create_system_users, the user will attempt to be created if it doesn't exist.
|
||||
"""
|
||||
user_exists = yield gen.maybe_future(self.system_user_exists(user))
|
||||
if not user_exists:
|
||||
@@ -324,9 +401,9 @@ class LocalAuthenticator(Authenticator):
|
||||
yield gen.maybe_future(self.add_system_user(user))
|
||||
else:
|
||||
raise KeyError("User %s does not exist." % user.name)
|
||||
|
||||
|
||||
yield gen.maybe_future(super().add_user(user))
|
||||
|
||||
|
||||
@staticmethod
|
||||
def system_user_exists(user):
|
||||
"""Check if the user exists on the system"""
|
||||
@@ -338,7 +415,10 @@ class LocalAuthenticator(Authenticator):
|
||||
return True
|
||||
|
||||
def add_system_user(self, user):
|
||||
"""Create a new Linux/UNIX user on the system. Works on FreeBSD and Linux, at least."""
|
||||
"""Create a new local UNIX user on the system.
|
||||
|
||||
Tested to work on FreeBSD and Linux, at least.
|
||||
"""
|
||||
name = user.name
|
||||
cmd = [ arg.replace('USERNAME', name) for arg in self.add_user_cmd ] + [name]
|
||||
self.log.info("Creating user: %s", ' '.join(map(pipes.quote, cmd)))
|
||||
@@ -350,18 +430,37 @@ class LocalAuthenticator(Authenticator):
|
||||
|
||||
|
||||
class PAMAuthenticator(LocalAuthenticator):
|
||||
"""Authenticate local Linux/UNIX users with PAM"""
|
||||
encoding = Unicode('utf8', config=True,
|
||||
help="""The encoding to use for PAM"""
|
||||
)
|
||||
service = Unicode('login', config=True,
|
||||
help="""The PAM service to use for authentication."""
|
||||
)
|
||||
|
||||
"""Authenticate local UNIX users with PAM"""
|
||||
|
||||
encoding = Unicode('utf8',
|
||||
help="""
|
||||
The text encoding to use when communicating with PAM
|
||||
"""
|
||||
).tag(config=True)
|
||||
|
||||
service = Unicode('login',
|
||||
help="""
|
||||
The name of the PAM service to use for authentication
|
||||
"""
|
||||
).tag(config=True)
|
||||
|
||||
open_sessions = Bool(True,
|
||||
help="""
|
||||
Whether to open a new PAM session when spawners are started.
|
||||
|
||||
This may trigger things like mounting shared filsystems,
|
||||
loading credentials, etc. depending on system configuration,
|
||||
but it does not always work.
|
||||
|
||||
If any errors are encountered when opening/closing PAM sessions,
|
||||
this is automatically set to False.
|
||||
"""
|
||||
).tag(config=True)
|
||||
|
||||
@gen.coroutine
|
||||
def authenticate(self, handler, data):
|
||||
"""Authenticate with PAM, and return the username if login is successful.
|
||||
|
||||
|
||||
Return None otherwise.
|
||||
"""
|
||||
username = data['username']
|
||||
@@ -369,23 +468,30 @@ class PAMAuthenticator(LocalAuthenticator):
|
||||
pamela.authenticate(username, data['password'], service=self.service)
|
||||
except pamela.PAMError as e:
|
||||
if handler is not None:
|
||||
self.log.warn("PAM Authentication failed (@%s): %s", handler.request.remote_ip, e)
|
||||
self.log.warning("PAM Authentication failed (%s@%s): %s", username, handler.request.remote_ip, e)
|
||||
else:
|
||||
self.log.warn("PAM Authentication failed: %s", e)
|
||||
self.log.warning("PAM Authentication failed: %s", e)
|
||||
else:
|
||||
return username
|
||||
|
||||
|
||||
def pre_spawn_start(self, user, spawner):
|
||||
"""Open PAM session for user"""
|
||||
"""Open PAM session for user if so configured"""
|
||||
if not self.open_sessions:
|
||||
return
|
||||
try:
|
||||
pamela.open_session(user.name, service=self.service)
|
||||
except pamela.PAMError as e:
|
||||
self.log.warn("Failed to open PAM session for %s: %s", user.name, e)
|
||||
|
||||
self.log.warning("Failed to open PAM session for %s: %s", user.name, e)
|
||||
self.log.warning("Disabling PAM sessions from now on.")
|
||||
self.open_sessions = False
|
||||
|
||||
def post_spawn_stop(self, user, spawner):
|
||||
"""Close PAM session for user"""
|
||||
"""Close PAM session for user if we were configured to opened one"""
|
||||
if not self.open_sessions:
|
||||
return
|
||||
try:
|
||||
pamela.close_session(user.name, service=self.service)
|
||||
except pamela.PAMError as e:
|
||||
self.log.warn("Failed to close PAM session for %s: %s", user.name, e)
|
||||
|
||||
self.log.warning("Failed to close PAM session for %s: %s", user.name, e)
|
||||
self.log.warning("Disabling PAM sessions from now on.")
|
||||
self.open_sessions = False
|
||||
|
93
jupyterhub/dbutil.py
Normal file
93
jupyterhub/dbutil.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Database utilities for JupyterHub"""
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
# Based on pgcontents.utils.migrate, used under the Apache license.
|
||||
|
||||
from contextlib import contextmanager
|
||||
import os
|
||||
from subprocess import check_call
|
||||
import sys
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
_here = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
ALEMBIC_INI_TEMPLATE_PATH = os.path.join(_here, 'alembic.ini')
|
||||
ALEMBIC_DIR = os.path.join(_here, 'alembic')
|
||||
|
||||
|
||||
def write_alembic_ini(alembic_ini='alembic.ini', db_url='sqlite:///jupyterhub.sqlite'):
|
||||
"""Write a complete alembic.ini from our template.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
|
||||
alembic_ini: str
|
||||
path to the alembic.ini file that should be written.
|
||||
db_url: str
|
||||
The SQLAlchemy database url, e.g. `sqlite:///jupyterhub.sqlite`.
|
||||
"""
|
||||
with open(ALEMBIC_INI_TEMPLATE_PATH) as f:
|
||||
alembic_ini_tpl = f.read()
|
||||
|
||||
with open(alembic_ini, 'w') as f:
|
||||
f.write(
|
||||
alembic_ini_tpl.format(
|
||||
alembic_dir=ALEMBIC_DIR,
|
||||
db_url=db_url,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _temp_alembic_ini(db_url):
|
||||
"""Context manager for temporary JupyterHub alembic directory
|
||||
|
||||
Temporarily write an alembic.ini file for use with alembic migration scripts.
|
||||
|
||||
Context manager yields alembic.ini path.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
|
||||
db_url: str
|
||||
The SQLAlchemy database url, e.g. `sqlite:///jupyterhub.sqlite`.
|
||||
|
||||
Returns
|
||||
-------
|
||||
|
||||
alembic_ini: str
|
||||
The path to the temporary alembic.ini that we have created.
|
||||
This file will be cleaned up on exit from the context manager.
|
||||
"""
|
||||
with TemporaryDirectory() as td:
|
||||
alembic_ini = os.path.join(td, 'alembic.ini')
|
||||
write_alembic_ini(alembic_ini, db_url)
|
||||
yield alembic_ini
|
||||
|
||||
|
||||
def upgrade(db_url, revision='head'):
|
||||
"""Upgrade the given database to revision.
|
||||
|
||||
db_url: str
|
||||
The SQLAlchemy database url, e.g. `sqlite:///jupyterhub.sqlite`.
|
||||
revision: str [default: head]
|
||||
The alembic revision to upgrade to.
|
||||
"""
|
||||
with _temp_alembic_ini(db_url) as alembic_ini:
|
||||
check_call(
|
||||
['alembic', '-c', alembic_ini, 'upgrade', revision]
|
||||
)
|
||||
|
||||
def _alembic(*args):
|
||||
"""Run an alembic command with a temporary alembic.ini"""
|
||||
with _temp_alembic_ini('sqlite:///jupyterhub.sqlite') as alembic_ini:
|
||||
check_call(
|
||||
['alembic', '-c', alembic_ini] + list(args)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
_alembic(*sys.argv[1:])
|
13
jupyterhub/emptyclass.py
Normal file
13
jupyterhub/emptyclass.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Simple empty class that returns itself for all functions called on it.
|
||||
This allows us to call any method of any name on this, and it'll return another
|
||||
instance of itself that'll allow any method to be called on it.
|
||||
|
||||
Primarily used to mock out the statsd client when statsd is not being used
|
||||
"""
|
||||
class EmptyClass:
|
||||
def empty_function(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def __getattr__(self, attr):
|
||||
return self.empty_function
|
@@ -6,6 +6,7 @@
|
||||
import re
|
||||
from datetime import timedelta
|
||||
from http.client import responses
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from jinja2 import TemplateNotFound
|
||||
|
||||
@@ -46,27 +47,42 @@ class BaseHandler(RequestHandler):
|
||||
@property
|
||||
def base_url(self):
|
||||
return self.settings.get('base_url', '/')
|
||||
|
||||
|
||||
@property
|
||||
def version_hash(self):
|
||||
return self.settings.get('version_hash', '')
|
||||
|
||||
|
||||
@property
|
||||
def subdomain_host(self):
|
||||
return self.settings.get('subdomain_host', '')
|
||||
|
||||
@property
|
||||
def domain(self):
|
||||
return self.settings['domain']
|
||||
|
||||
@property
|
||||
def db(self):
|
||||
return self.settings['db']
|
||||
|
||||
|
||||
@property
|
||||
def users(self):
|
||||
return self.settings.setdefault('users', {})
|
||||
|
||||
|
||||
@property
|
||||
def services(self):
|
||||
return self.settings.setdefault('services', {})
|
||||
@property
|
||||
def hub(self):
|
||||
return self.settings['hub']
|
||||
|
||||
|
||||
@property
|
||||
def proxy(self):
|
||||
return self.settings['proxy']
|
||||
|
||||
|
||||
@property
|
||||
def statsd(self):
|
||||
return self.settings['statsd']
|
||||
|
||||
@property
|
||||
def authenticator(self):
|
||||
return self.settings.get('authenticator', None)
|
||||
@@ -75,28 +91,28 @@ class BaseHandler(RequestHandler):
|
||||
"""Roll back any uncommitted transactions from the handler."""
|
||||
self.db.rollback()
|
||||
super().finish(*args, **kwargs)
|
||||
|
||||
|
||||
#---------------------------------------------------------------
|
||||
# Security policies
|
||||
#---------------------------------------------------------------
|
||||
|
||||
|
||||
@property
|
||||
def csp_report_uri(self):
|
||||
return self.settings.get('csp_report_uri',
|
||||
url_path_join(self.hub.server.base_url, 'security/csp-report')
|
||||
)
|
||||
|
||||
|
||||
@property
|
||||
def content_security_policy(self):
|
||||
"""The default Content-Security-Policy header
|
||||
|
||||
|
||||
Can be overridden by defining Content-Security-Policy in settings['headers']
|
||||
"""
|
||||
return '; '.join([
|
||||
"frame-ancestors 'self'",
|
||||
"report-uri " + self.csp_report_uri,
|
||||
])
|
||||
|
||||
|
||||
def set_default_headers(self):
|
||||
"""
|
||||
Set any headers passed as tornado_settings['headers'].
|
||||
@@ -105,7 +121,7 @@ class BaseHandler(RequestHandler):
|
||||
"""
|
||||
headers = self.settings.get('headers', {})
|
||||
headers.setdefault("Content-Security-Policy", self.content_security_policy)
|
||||
|
||||
|
||||
for header_name, header_content in headers.items():
|
||||
self.set_header(header_name, header_content)
|
||||
|
||||
@@ -116,7 +132,7 @@ class BaseHandler(RequestHandler):
|
||||
@property
|
||||
def admin_users(self):
|
||||
return self.settings.setdefault('admin_users', set())
|
||||
|
||||
|
||||
@property
|
||||
def cookie_max_age_days(self):
|
||||
return self.settings.get('cookie_max_age_days', None)
|
||||
@@ -132,8 +148,8 @@ class BaseHandler(RequestHandler):
|
||||
if orm_token is None:
|
||||
return None
|
||||
else:
|
||||
return orm_token.user
|
||||
|
||||
return orm_token.user or orm_token.service
|
||||
|
||||
def _user_for_cookie(self, cookie_name, cookie_value=None):
|
||||
"""Get the User for a given cookie, if there is one"""
|
||||
cookie_id = self.get_secure_cookie(
|
||||
@@ -143,41 +159,41 @@ class BaseHandler(RequestHandler):
|
||||
)
|
||||
def clear():
|
||||
self.clear_cookie(cookie_name, path=self.hub.server.base_url)
|
||||
|
||||
|
||||
if cookie_id is None:
|
||||
if self.get_cookie(cookie_name):
|
||||
self.log.warn("Invalid or expired cookie token")
|
||||
self.log.warning("Invalid or expired cookie token")
|
||||
clear()
|
||||
return
|
||||
cookie_id = cookie_id.decode('utf8', 'replace')
|
||||
u = self.db.query(orm.User).filter(orm.User.cookie_id==cookie_id).first()
|
||||
user = self._user_from_orm(u)
|
||||
if user is None:
|
||||
self.log.warn("Invalid cookie token")
|
||||
self.log.warning("Invalid cookie token")
|
||||
# have cookie, but it's not valid. Clear it and start over.
|
||||
clear()
|
||||
return user
|
||||
|
||||
|
||||
def _user_from_orm(self, orm_user):
|
||||
"""return User wrapper from orm.User object"""
|
||||
if orm_user is None:
|
||||
return
|
||||
return self.users[orm_user]
|
||||
|
||||
|
||||
def get_current_user_cookie(self):
|
||||
"""get_current_user from a cookie token"""
|
||||
return self._user_for_cookie(self.hub.server.cookie_name)
|
||||
|
||||
|
||||
def get_current_user(self):
|
||||
"""get current username"""
|
||||
user = self.get_current_user_token()
|
||||
if user is not None:
|
||||
return user
|
||||
return self.get_current_user_cookie()
|
||||
|
||||
|
||||
def find_user(self, name):
|
||||
"""Get a user by name
|
||||
|
||||
|
||||
return None if no such user
|
||||
"""
|
||||
orm_user = orm.User.find(db=self.db, name=name)
|
||||
@@ -192,57 +208,72 @@ class BaseHandler(RequestHandler):
|
||||
self.db.add(u)
|
||||
self.db.commit()
|
||||
user = self._user_from_orm(u)
|
||||
self.authenticator.add_user(user)
|
||||
return user
|
||||
|
||||
|
||||
def clear_login_cookie(self, name=None):
|
||||
if name is None:
|
||||
user = self.get_current_user()
|
||||
else:
|
||||
user = self.find_user(name)
|
||||
kwargs = {}
|
||||
if self.subdomain_host:
|
||||
kwargs['domain'] = self.domain
|
||||
if user and user.server:
|
||||
self.clear_cookie(user.server.cookie_name, path=user.server.base_url)
|
||||
self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url)
|
||||
|
||||
self.clear_cookie(user.server.cookie_name, path=user.server.base_url, **kwargs)
|
||||
self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url, **kwargs)
|
||||
self.clear_cookie('jupyterhub-services', path=url_path_join(self.base_url, 'services'))
|
||||
|
||||
def _set_user_cookie(self, user, server):
|
||||
# tornado <4.2 have a bug that consider secure==True as soon as
|
||||
# 'secure' kwarg is passed to set_secure_cookie
|
||||
if self.request.protocol == 'https':
|
||||
kwargs = {'secure': True}
|
||||
else:
|
||||
kwargs = {}
|
||||
if self.subdomain_host:
|
||||
kwargs['domain'] = self.domain
|
||||
self.log.debug("Setting cookie for %s: %s, %s", user.name, server.cookie_name, kwargs)
|
||||
self.set_secure_cookie(
|
||||
server.cookie_name,
|
||||
user.cookie_id,
|
||||
path=server.base_url,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def set_service_cookie(self, user):
|
||||
"""set the login cookie for services"""
|
||||
self._set_user_cookie(user, orm.Server(
|
||||
cookie_name='jupyterhub-services',
|
||||
base_url=url_path_join(self.base_url, 'services')
|
||||
))
|
||||
|
||||
def set_server_cookie(self, user):
|
||||
"""set the login cookie for the single-user server"""
|
||||
# tornado <4.2 have a bug that consider secure==True as soon as
|
||||
# 'secure' kwarg is passed to set_secure_cookie
|
||||
if self.request.protocol == 'https':
|
||||
kwargs = {'secure':True}
|
||||
else:
|
||||
kwargs = {}
|
||||
self.set_secure_cookie(
|
||||
user.server.cookie_name,
|
||||
user.cookie_id,
|
||||
path=user.server.base_url,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
self._set_user_cookie(user, user.server)
|
||||
|
||||
def set_hub_cookie(self, user):
|
||||
"""set the login cookie for the Hub"""
|
||||
# tornado <4.2 have a bug that consider secure==True as soon as
|
||||
# 'secure' kwarg is passed to set_secure_cookie
|
||||
if self.request.protocol == 'https':
|
||||
kwargs = {'secure':True}
|
||||
else:
|
||||
kwargs = {}
|
||||
self.set_secure_cookie(
|
||||
self.hub.server.cookie_name,
|
||||
user.cookie_id,
|
||||
path=self.hub.server.base_url,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
self._set_user_cookie(user, self.hub.server)
|
||||
|
||||
def set_login_cookie(self, user):
|
||||
"""Set login cookies for the Hub and single-user server."""
|
||||
if self.subdomain_host and not self.request.host.startswith(self.domain):
|
||||
self.log.warning(
|
||||
"Possibly setting cookie on wrong domain: %s != %s",
|
||||
self.request.host, self.domain)
|
||||
# create and set a new cookie token for the single-user server
|
||||
if user.server:
|
||||
self.set_server_cookie(user)
|
||||
|
||||
|
||||
# set single cookie for services
|
||||
if self.db.query(orm.Service).filter(orm.Service.server != None).first():
|
||||
self.set_service_cookie(user)
|
||||
|
||||
# create and set a new cookie token for the hub
|
||||
if not self.get_current_user_cookie():
|
||||
self.set_hub_cookie(user)
|
||||
|
||||
|
||||
@gen.coroutine
|
||||
def authenticate(self, data):
|
||||
auth = self.authenticator
|
||||
@@ -268,7 +299,7 @@ class BaseHandler(RequestHandler):
|
||||
@property
|
||||
def spawner_class(self):
|
||||
return self.settings.get('spawner_class', LocalProcessSpawner)
|
||||
|
||||
|
||||
@gen.coroutine
|
||||
def spawn_single_user(self, user, options=None):
|
||||
if user.spawn_pending:
|
||||
@@ -280,7 +311,7 @@ class BaseHandler(RequestHandler):
|
||||
@gen.coroutine
|
||||
def finish_user_spawn(f=None):
|
||||
"""Finish the user spawn by registering listeners and notifying the proxy.
|
||||
|
||||
|
||||
If the spawner is slow to start, this is passed as an async callback,
|
||||
otherwise it is called immediately.
|
||||
"""
|
||||
@@ -289,38 +320,53 @@ class BaseHandler(RequestHandler):
|
||||
return
|
||||
toc = IOLoop.current().time()
|
||||
self.log.info("User %s server took %.3f seconds to start", user.name, toc-tic)
|
||||
self.statsd.timing('spawner.success', (toc - tic) * 1000)
|
||||
yield self.proxy.add_user(user)
|
||||
user.spawner.add_poll_callback(self.user_stopped, user)
|
||||
|
||||
|
||||
try:
|
||||
yield gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), f)
|
||||
except gen.TimeoutError:
|
||||
if user.spawn_pending:
|
||||
# waiting_for_response indicates server process has started,
|
||||
# but is yet to become responsive.
|
||||
if not user.waiting_for_response:
|
||||
# still in Spawner.start, which is taking a long time
|
||||
# we shouldn't poll while spawn is incomplete.
|
||||
self.log.warning("User %s's server is slow to start (timeout=%s)",
|
||||
user.name, self.slow_spawn_timeout)
|
||||
# schedule finish for when the user finishes spawning
|
||||
IOLoop.current().add_future(f, finish_user_spawn)
|
||||
else:
|
||||
# start has finished, but the server hasn't come up
|
||||
# check if the server died while we were waiting
|
||||
status = yield user.spawner.poll()
|
||||
if status is None:
|
||||
# hit timeout, but spawn is still pending
|
||||
self.log.warn("User %s server is slow to start", user.name)
|
||||
# hit timeout, but server's running. Hope that it'll show up soon enough,
|
||||
# though it's possible that it started at the wrong URL
|
||||
self.log.warning("User %s's server is slow to become responsive (timeout=%s)",
|
||||
user.name, self.slow_spawn_timeout)
|
||||
self.log.debug("Expecting server for %s at: %s", user.name, user.server.url)
|
||||
# schedule finish for when the user finishes spawning
|
||||
IOLoop.current().add_future(f, finish_user_spawn)
|
||||
else:
|
||||
toc = IOLoop.current().time()
|
||||
self.statsd.timing('spawner.failure', (toc - tic) * 1000)
|
||||
raise web.HTTPError(500, "Spawner failed to start [status=%s]" % status)
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
yield finish_user_spawn()
|
||||
|
||||
|
||||
@gen.coroutine
|
||||
def user_stopped(self, user):
|
||||
"""Callback that fires when the spawner has stopped"""
|
||||
status = yield user.spawner.poll()
|
||||
if status is None:
|
||||
status = 'unknown'
|
||||
self.log.warn("User %s server stopped, with exit code: %s",
|
||||
self.log.warning("User %s server stopped, with exit code: %s",
|
||||
user.name, status,
|
||||
)
|
||||
yield self.proxy.delete_user(user)
|
||||
yield user.stop()
|
||||
|
||||
|
||||
@gen.coroutine
|
||||
def stop_single_user(self, user):
|
||||
if user.stop_pending:
|
||||
@@ -331,7 +377,7 @@ class BaseHandler(RequestHandler):
|
||||
@gen.coroutine
|
||||
def finish_stop(f=None):
|
||||
"""Finish the stop action by noticing that the user is stopped.
|
||||
|
||||
|
||||
If the spawner is slow to stop, this is passed as an async callback,
|
||||
otherwise it is called immediately.
|
||||
"""
|
||||
@@ -340,13 +386,13 @@ class BaseHandler(RequestHandler):
|
||||
return
|
||||
toc = IOLoop.current().time()
|
||||
self.log.info("User %s server took %.3f seconds to stop", user.name, toc-tic)
|
||||
|
||||
|
||||
try:
|
||||
yield gen.with_timeout(timedelta(seconds=self.slow_stop_timeout), f)
|
||||
except gen.TimeoutError:
|
||||
if user.stop_pending:
|
||||
# hit timeout, but stop is still pending
|
||||
self.log.warn("User %s server is slow to stop", user.name)
|
||||
self.log.warning("User %s server is slow to stop", user.name)
|
||||
# schedule finish for when the server finishes stopping
|
||||
IOLoop.current().add_future(f, finish_stop)
|
||||
else:
|
||||
@@ -385,6 +431,7 @@ class BaseHandler(RequestHandler):
|
||||
"""render custom error pages"""
|
||||
exc_info = kwargs.get('exc_info')
|
||||
message = ''
|
||||
exception = None
|
||||
status_message = responses.get(status_code, 'Unknown HTTP Error')
|
||||
if exc_info:
|
||||
exception = exc_info[1]
|
||||
@@ -426,33 +473,58 @@ class Template404(BaseHandler):
|
||||
|
||||
class PrefixRedirectHandler(BaseHandler):
|
||||
"""Redirect anything outside a prefix inside.
|
||||
|
||||
|
||||
Redirects /foo to /prefix/foo, etc.
|
||||
"""
|
||||
def get(self):
|
||||
path = self.request.uri[len(self.base_url):]
|
||||
uri = self.request.uri
|
||||
if uri.startswith(self.base_url):
|
||||
path = self.request.uri[len(self.base_url):]
|
||||
else:
|
||||
path = self.request.path
|
||||
self.redirect(url_path_join(
|
||||
self.hub.server.base_url, path,
|
||||
), permanent=False)
|
||||
|
||||
|
||||
class UserSpawnHandler(BaseHandler):
|
||||
"""Requests to /user/name handled by the Hub
|
||||
should result in spawning the single-user server and
|
||||
being redirected to the original.
|
||||
"""Redirect requests to /user/name/* handled by the Hub.
|
||||
|
||||
If logged in, spawn a single-user server and redirect request.
|
||||
If a user, alice, requests /user/bob/notebooks/mynotebook.ipynb,
|
||||
she will be redirected to /hub/user/bob/notebooks/mynotebook.ipynb,
|
||||
which will be handled by this handler,
|
||||
which will in turn send her to /user/alice/notebooks/mynotebook.ipynb.
|
||||
"""
|
||||
|
||||
@gen.coroutine
|
||||
def get(self, name):
|
||||
def get(self, name, user_path):
|
||||
current_user = self.get_current_user()
|
||||
if current_user and current_user.name == name:
|
||||
# logged in, spawn the server
|
||||
# If people visit /user/:name directly on the Hub,
|
||||
# the redirects will just loop, because the proxy is bypassed.
|
||||
# Try to check for that and warn,
|
||||
# though the user-facing behavior is unchainged
|
||||
host_info = urlparse(self.request.full_url())
|
||||
port = host_info.port
|
||||
if not port:
|
||||
port = 443 if host_info.scheme == 'https' else 80
|
||||
if port != self.proxy.public_server.port and port == self.hub.server.port:
|
||||
self.log.warning("""
|
||||
Detected possible direct connection to Hub's private ip: %s, bypassing proxy.
|
||||
This will result in a redirect loop.
|
||||
Make sure to connect to the proxied public URL %s
|
||||
""", self.request.full_url(), self.proxy.public_server.url)
|
||||
|
||||
# logged in as correct user, spawn the server
|
||||
if current_user.spawner:
|
||||
if current_user.spawn_pending:
|
||||
# spawn has started, but not finished
|
||||
self.statsd.incr('redirects.user_spawn_pending', 1)
|
||||
html = self.render_template("spawn_pending.html", user=current_user)
|
||||
self.finish(html)
|
||||
return
|
||||
|
||||
|
||||
# spawn has supposedly finished, check on the status
|
||||
status = yield current_user.spawner.poll()
|
||||
if status is not None:
|
||||
@@ -465,25 +537,58 @@ class UserSpawnHandler(BaseHandler):
|
||||
self.set_login_cookie(current_user)
|
||||
without_prefix = self.request.uri[len(self.hub.server.base_url):]
|
||||
target = url_path_join(self.base_url, without_prefix)
|
||||
if self.subdomain_host:
|
||||
target = current_user.host + target
|
||||
self.redirect(target)
|
||||
self.statsd.incr('redirects.user_after_login')
|
||||
elif current_user:
|
||||
# logged in as a different user, redirect
|
||||
self.statsd.incr('redirects.user_to_user', 1)
|
||||
target = url_path_join(current_user.url, user_path or '')
|
||||
self.redirect(target)
|
||||
else:
|
||||
# not logged in to the right user,
|
||||
# clear any cookies and reload (will redirect to login)
|
||||
# not logged in, clear any cookies and reload
|
||||
self.statsd.incr('redirects.user_to_login', 1)
|
||||
self.clear_login_cookie()
|
||||
self.redirect(url_concat(
|
||||
self.settings['login_url'],
|
||||
{'next': self.request.uri,
|
||||
}))
|
||||
{'next': self.request.uri},
|
||||
))
|
||||
|
||||
|
||||
class UserRedirectHandler(BaseHandler):
|
||||
"""Redirect requests to user servers.
|
||||
|
||||
Allows public linking to "this file on your server".
|
||||
|
||||
/user-redirect/path/to/foo will redirect to /user/:name/path/to/foo
|
||||
|
||||
If the user is not logged in, send to login URL, redirecting back here.
|
||||
|
||||
.. versionadded:: 0.7
|
||||
"""
|
||||
@web.authenticated
|
||||
def get(self, path):
|
||||
user = self.get_current_user()
|
||||
url = url_path_join(user.url, path)
|
||||
self.redirect(url)
|
||||
|
||||
|
||||
class CSPReportHandler(BaseHandler):
|
||||
'''Accepts a content security policy violation report'''
|
||||
@web.authenticated
|
||||
def post(self):
|
||||
'''Log a content security policy violation report'''
|
||||
self.log.warn("Content security violation: %s",
|
||||
self.request.body.decode('utf8', 'replace'))
|
||||
self.log.warning(
|
||||
"Content security violation: %s",
|
||||
self.request.body.decode('utf8', 'replace')
|
||||
)
|
||||
# Report it to statsd as well
|
||||
self.statsd.incr('csp_report')
|
||||
|
||||
|
||||
default_handlers = [
|
||||
(r'/user/([^/]+)/?.*', UserSpawnHandler),
|
||||
(r'/user/([^/]+)(/.*)?', UserSpawnHandler),
|
||||
(r'/user-redirect/(.*)?', UserRedirectHandler),
|
||||
(r'/security/csp-report', CSPReportHandler),
|
||||
]
|
||||
|
@@ -3,6 +3,8 @@
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from tornado.escape import url_escape
|
||||
from tornado import gen
|
||||
|
||||
@@ -15,10 +17,11 @@ class LogoutHandler(BaseHandler):
|
||||
user = self.get_current_user()
|
||||
if user:
|
||||
self.log.info("User logged out: %s", user.name)
|
||||
self.clear_login_cookie()
|
||||
for name in user.other_user_cookies:
|
||||
self.clear_login_cookie(name)
|
||||
user.other_user_cookies = set([])
|
||||
self.clear_login_cookie()
|
||||
for name in user.other_user_cookies:
|
||||
self.clear_login_cookie(name)
|
||||
user.other_user_cookies = set([])
|
||||
self.statsd.incr('logout')
|
||||
self.redirect(self.hub.server.base_url, permanent=False)
|
||||
|
||||
|
||||
@@ -35,15 +38,19 @@ class LoginHandler(BaseHandler):
|
||||
)
|
||||
|
||||
def get(self):
|
||||
self.statsd.incr('login.request')
|
||||
next_url = self.get_argument('next', '')
|
||||
if not next_url.startswith('/'):
|
||||
# disallow non-absolute next URLs (e.g. full URLs)
|
||||
if (next_url + '/').startswith('%s://%s/' % (self.request.protocol, self.request.host)):
|
||||
# treat absolute URLs for our host as absolute paths:
|
||||
next_url = urlparse(next_url).path
|
||||
elif not next_url.startswith('/'):
|
||||
# disallow non-absolute next URLs (e.g. full URLs to other hosts)
|
||||
next_url = ''
|
||||
user = self.get_current_user()
|
||||
if user:
|
||||
if not next_url:
|
||||
if user.running:
|
||||
next_url = user.server.base_url
|
||||
next_url = user.url
|
||||
else:
|
||||
next_url = self.hub.server.base_url
|
||||
# set new login cookie
|
||||
@@ -61,8 +68,13 @@ class LoginHandler(BaseHandler):
|
||||
for arg in self.request.arguments:
|
||||
data[arg] = self.get_argument(arg)
|
||||
|
||||
auth_timer = self.statsd.timer('login.authenticate').start()
|
||||
username = yield self.authenticate(data)
|
||||
auth_timer.stop(send=False)
|
||||
|
||||
if username:
|
||||
self.statsd.incr('login.success')
|
||||
self.statsd.timing('login.authenticate.success', auth_timer.ms)
|
||||
user = self.user_from_username(username)
|
||||
already_running = False
|
||||
if user.spawner:
|
||||
@@ -78,7 +90,9 @@ class LoginHandler(BaseHandler):
|
||||
self.redirect(next_url)
|
||||
self.log.info("User logged in: %s", username)
|
||||
else:
|
||||
self.log.debug("Failed login for %s", username)
|
||||
self.statsd.incr('login.failure')
|
||||
self.statsd.timing('login.authenticate.failure', auth_timer.ms)
|
||||
self.log.debug("Failed login for %s", data.get('username', 'unknown user'))
|
||||
html = self._render(
|
||||
login_error='Invalid username or password',
|
||||
username=username,
|
||||
|
@@ -3,36 +3,56 @@
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
from http.client import responses
|
||||
|
||||
from jinja2 import TemplateNotFound
|
||||
from tornado import web, gen
|
||||
|
||||
from .. import orm
|
||||
from ..utils import admin_only, url_path_join
|
||||
from .base import BaseHandler
|
||||
from .login import LoginHandler
|
||||
|
||||
|
||||
class RootHandler(BaseHandler):
|
||||
"""Render the Hub root page.
|
||||
|
||||
|
||||
If next argument is passed by single-user server,
|
||||
redirect to base_url + single-user page.
|
||||
|
||||
If logged in, redirects to:
|
||||
|
||||
|
||||
- single-user server if running
|
||||
- hub home, otherwise
|
||||
|
||||
|
||||
Otherwise, renders login page.
|
||||
"""
|
||||
def get(self):
|
||||
next_url = self.get_argument('next', '')
|
||||
if next_url and not next_url.startswith('/'):
|
||||
self.log.warning("Disallowing redirect outside JupyterHub: %r", next_url)
|
||||
next_url = ''
|
||||
if next_url and next_url.startswith(url_path_join(self.base_url, 'user/')):
|
||||
# add /hub/ prefix, to ensure we redirect to the right user's server.
|
||||
# The next request will be handled by UserSpawnHandler,
|
||||
# ultimately redirecting to the logged-in user's server.
|
||||
without_prefix = next_url[len(self.base_url):]
|
||||
next_url = url_path_join(self.hub.server.base_url, without_prefix)
|
||||
self.log.warning("Redirecting %s to %s. For sharing public links, use /user-redirect/",
|
||||
self.request.uri, next_url,
|
||||
)
|
||||
self.redirect(next_url)
|
||||
return
|
||||
user = self.get_current_user()
|
||||
if user:
|
||||
if user.running:
|
||||
url = user.server.base_url
|
||||
url = user.url
|
||||
self.log.debug("User is running: %s", url)
|
||||
self.set_login_cookie(user) # set cookie
|
||||
else:
|
||||
url = url_path_join(self.hub.server.base_url, 'home')
|
||||
self.log.debug("User is not running: %s", url)
|
||||
self.redirect(url)
|
||||
return
|
||||
url = url_path_join(self.hub.server.base_url, 'login')
|
||||
else:
|
||||
url = self.authenticator.login_url(self.base_url)
|
||||
self.redirect(url)
|
||||
|
||||
|
||||
@@ -40,18 +60,23 @@ class HomeHandler(BaseHandler):
|
||||
"""Render the user's home page."""
|
||||
|
||||
@web.authenticated
|
||||
@gen.coroutine
|
||||
def get(self):
|
||||
user = self.get_current_user()
|
||||
if user.running:
|
||||
# trigger poll_and_notify event in case of a server that died
|
||||
yield user.spawner.poll_and_notify()
|
||||
html = self.render_template('home.html',
|
||||
user=self.get_current_user(),
|
||||
user=user,
|
||||
)
|
||||
self.finish(html)
|
||||
|
||||
|
||||
class SpawnHandler(BaseHandler):
|
||||
"""Handle spawning of single-user servers via form.
|
||||
|
||||
|
||||
GET renders the form, POST handles form submission.
|
||||
|
||||
|
||||
Only enabled when Spawner.options_form is defined.
|
||||
"""
|
||||
def _render_form(self, message=''):
|
||||
@@ -67,7 +92,7 @@ class SpawnHandler(BaseHandler):
|
||||
"""GET renders form for spawning with user-specified options"""
|
||||
user = self.get_current_user()
|
||||
if user.running:
|
||||
url = user.server.base_url
|
||||
url = user.url
|
||||
self.log.debug("User is running: %s", url)
|
||||
self.redirect(url)
|
||||
return
|
||||
@@ -75,16 +100,15 @@ class SpawnHandler(BaseHandler):
|
||||
self.finish(self._render_form())
|
||||
else:
|
||||
# not running, no form. Trigger spawn.
|
||||
url = url_path_join(self.base_url, 'user', user.name)
|
||||
self.redirect(url)
|
||||
|
||||
self.redirect(user.url)
|
||||
|
||||
@web.authenticated
|
||||
@gen.coroutine
|
||||
def post(self):
|
||||
"""POST spawns with user-specified options"""
|
||||
user = self.get_current_user()
|
||||
if user.running:
|
||||
url = user.server.base_url
|
||||
url = user.url
|
||||
self.log.warning("User is already running: %s", url)
|
||||
self.redirect(url)
|
||||
return
|
||||
@@ -93,15 +117,15 @@ class SpawnHandler(BaseHandler):
|
||||
form_options[key] = [ bs.decode('utf8') for bs in byte_list ]
|
||||
for key, byte_list in self.request.files.items():
|
||||
form_options["%s_file"%key] = byte_list
|
||||
options = user.spawner.options_from_form(form_options)
|
||||
try:
|
||||
options = user.spawner.options_from_form(form_options)
|
||||
yield self.spawn_single_user(user, options=options)
|
||||
except Exception as e:
|
||||
self.log.error("Failed to spawn single-user server with form", exc_info=True)
|
||||
self.finish(self._render_form(str(e)))
|
||||
return
|
||||
self.set_login_cookie(user)
|
||||
url = user.server.base_url
|
||||
url = user.url
|
||||
self.redirect(url)
|
||||
|
||||
class AdminHandler(BaseHandler):
|
||||
@@ -122,14 +146,14 @@ class AdminHandler(BaseHandler):
|
||||
}
|
||||
sorts = self.get_arguments('sort') or default_sort
|
||||
orders = self.get_arguments('order')
|
||||
|
||||
|
||||
for bad in set(sorts).difference(available):
|
||||
self.log.warn("ignoring invalid sort: %r", bad)
|
||||
self.log.warning("ignoring invalid sort: %r", bad)
|
||||
sorts.remove(bad)
|
||||
for bad in set(orders).difference({'asc', 'desc'}):
|
||||
self.log.warn("ignoring invalid order: %r", bad)
|
||||
self.log.warning("ignoring invalid order: %r", bad)
|
||||
orders.remove(bad)
|
||||
|
||||
|
||||
# add default sort as secondary
|
||||
for s in default_sort:
|
||||
if s not in sorts:
|
||||
@@ -139,17 +163,17 @@ class AdminHandler(BaseHandler):
|
||||
orders.append(default_order[col])
|
||||
else:
|
||||
orders = orders[:len(sorts)]
|
||||
|
||||
|
||||
# this could be one incomprehensible nested list comprehension
|
||||
# get User columns
|
||||
cols = [ getattr(orm.User, mapping.get(c, c)) for c in sorts ]
|
||||
# get User.col.desc() order objects
|
||||
ordered = [ getattr(c, o)() for c, o in zip(cols, orders) ]
|
||||
|
||||
|
||||
users = self.db.query(orm.User).order_by(*ordered)
|
||||
users = [ self._user_from_orm(u) for u in users ]
|
||||
running = [ u for u in users if u.running ]
|
||||
|
||||
|
||||
html = self.render_template('admin.html',
|
||||
user=self.get_current_user(),
|
||||
admin_access=self.settings.get('admin_access', False),
|
||||
@@ -160,9 +184,43 @@ class AdminHandler(BaseHandler):
|
||||
self.finish(html)
|
||||
|
||||
|
||||
class ProxyErrorHandler(BaseHandler):
|
||||
"""Handler for rendering proxy error pages"""
|
||||
|
||||
def get(self, status_code_s):
|
||||
status_code = int(status_code_s)
|
||||
status_message = responses.get(status_code, 'Unknown HTTP Error')
|
||||
# build template namespace
|
||||
|
||||
hub_home = url_path_join(self.hub.server.base_url, 'home')
|
||||
message_html = ''
|
||||
if status_code == 503:
|
||||
message_html = ' '.join([
|
||||
"Your server appears to be down.",
|
||||
"Try restarting it <a href='%s'>from the hub</a>" % hub_home
|
||||
])
|
||||
ns = dict(
|
||||
status_code=status_code,
|
||||
status_message=status_message,
|
||||
message_html=message_html,
|
||||
logo_url=hub_home,
|
||||
)
|
||||
|
||||
self.set_header('Content-Type', 'text/html')
|
||||
# render the template
|
||||
try:
|
||||
html = self.render_template('%s.html' % status_code, **ns)
|
||||
except TemplateNotFound:
|
||||
self.log.debug("No template for %d", status_code)
|
||||
html = self.render_template('error.html', **ns)
|
||||
|
||||
self.write(html)
|
||||
|
||||
|
||||
default_handlers = [
|
||||
(r'/', RootHandler),
|
||||
(r'/home', HomeHandler),
|
||||
(r'/admin', AdminHandler),
|
||||
(r'/spawn', SpawnHandler),
|
||||
(r'/error/(\d+)', ProxyErrorHandler),
|
||||
]
|
||||
|
@@ -1,6 +1,7 @@
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
import os
|
||||
from tornado.web import StaticFileHandler
|
||||
|
||||
class CacheControlStaticFilesHandler(StaticFileHandler):
|
||||
@@ -14,4 +15,14 @@ class CacheControlStaticFilesHandler(StaticFileHandler):
|
||||
def set_extra_headers(self, path):
|
||||
if "v" not in self.request.arguments:
|
||||
self.add_header("Cache-Control", "no-cache")
|
||||
|
||||
|
||||
class LogoHandler(StaticFileHandler):
|
||||
"""A singular handler for serving the logo."""
|
||||
def get(self):
|
||||
return super().get('')
|
||||
|
||||
@classmethod
|
||||
def get_absolute_path(cls, root, path):
|
||||
"""We only serve one file, ignore relative path"""
|
||||
return os.path.abspath(root)
|
||||
|
||||
|
@@ -4,15 +4,13 @@
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
from datetime import datetime
|
||||
import errno
|
||||
import json
|
||||
import socket
|
||||
|
||||
from tornado import gen
|
||||
from tornado.log import app_log
|
||||
from tornado.httpclient import HTTPRequest, AsyncHTTPClient
|
||||
|
||||
from sqlalchemy.types import TypeDecorator, VARCHAR
|
||||
from sqlalchemy.types import TypeDecorator, TEXT
|
||||
from sqlalchemy import (
|
||||
inspect,
|
||||
Column, Integer, ForeignKey, Unicode, Boolean,
|
||||
@@ -22,11 +20,11 @@ from sqlalchemy.ext.declarative import declarative_base, declared_attr
|
||||
from sqlalchemy.orm import sessionmaker, relationship
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlalchemy.sql.expression import bindparam
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, Table
|
||||
|
||||
from .utils import (
|
||||
random_port, url_path_join, wait_for_server, wait_for_http_server,
|
||||
new_token, hash_token, compare_token, localhost,
|
||||
new_token, hash_token, compare_token, can_connect,
|
||||
)
|
||||
|
||||
|
||||
@@ -39,7 +37,7 @@ class JSONDict(TypeDecorator):
|
||||
|
||||
"""
|
||||
|
||||
impl = VARCHAR
|
||||
impl = TEXT
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if value is not None:
|
||||
@@ -59,26 +57,26 @@ Base.log = app_log
|
||||
|
||||
class Server(Base):
|
||||
"""The basic state of a server
|
||||
|
||||
|
||||
connection and cookie info
|
||||
"""
|
||||
__tablename__ = 'servers'
|
||||
id = Column(Integer, primary_key=True)
|
||||
proto = Column(Unicode, default='http')
|
||||
ip = Column(Unicode, default='')
|
||||
proto = Column(Unicode(15), default='http')
|
||||
ip = Column(Unicode(255), default='') # could also be a DNS name
|
||||
port = Column(Integer, default=random_port)
|
||||
base_url = Column(Unicode, default='/')
|
||||
cookie_name = Column(Unicode, default='cookie')
|
||||
|
||||
base_url = Column(Unicode(255), default='/')
|
||||
cookie_name = Column(Unicode(255), default='cookie')
|
||||
|
||||
def __repr__(self):
|
||||
return "<Server(%s:%s)>" % (self.ip, self.port)
|
||||
|
||||
|
||||
@property
|
||||
def host(self):
|
||||
ip = self.ip
|
||||
if ip in {'', '0.0.0.0'}:
|
||||
# when listening on all interfaces, connect to localhost
|
||||
ip = localhost()
|
||||
ip = '127.0.0.1'
|
||||
return "{proto}://{ip}:{port}".format(
|
||||
proto=self.proto,
|
||||
ip=ip,
|
||||
@@ -91,52 +89,34 @@ class Server(Base):
|
||||
host=self.host,
|
||||
uri=self.base_url,
|
||||
)
|
||||
|
||||
|
||||
@property
|
||||
def bind_url(self):
|
||||
"""representation of URL used for binding
|
||||
|
||||
|
||||
Never used in APIs, only logging,
|
||||
since it can be non-connectable value, such as '', meaning all interfaces.
|
||||
"""
|
||||
if self.ip in {'', '0.0.0.0'}:
|
||||
return self.url.replace('localhost', self.ip or '*', 1)
|
||||
return self.url.replace('127.0.0.1', self.ip or '*', 1)
|
||||
return self.url
|
||||
|
||||
|
||||
@gen.coroutine
|
||||
def wait_up(self, timeout=10, http=False):
|
||||
"""Wait for this server to come up"""
|
||||
if http:
|
||||
yield wait_for_http_server(self.url, timeout=timeout)
|
||||
else:
|
||||
yield wait_for_server(self.ip or localhost(), self.port, timeout=timeout)
|
||||
|
||||
yield wait_for_server(self.ip or '127.0.0.1', self.port, timeout=timeout)
|
||||
|
||||
def is_up(self):
|
||||
"""Is the server accepting connections?"""
|
||||
try:
|
||||
socket.create_connection((self.ip or localhost(), self.port))
|
||||
except socket.error as e:
|
||||
if e.errno == errno.ENETUNREACH:
|
||||
try:
|
||||
socket.create_connection((self.ip or '127.0.0.1', self.port))
|
||||
except socket.error as e:
|
||||
if e.errno == errno.ECONNREFUSED:
|
||||
return False
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
return True
|
||||
elif e.errno == errno.ECONNREFUSED:
|
||||
return False
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
return True
|
||||
return can_connect(self.ip or '127.0.0.1', self.port)
|
||||
|
||||
|
||||
class Proxy(Base):
|
||||
"""A configurable-http-proxy instance.
|
||||
|
||||
|
||||
A proxy consists of the API server info and the public-facing server info,
|
||||
plus an auth token for configuring the proxy table.
|
||||
"""
|
||||
@@ -147,7 +127,7 @@ class Proxy(Base):
|
||||
public_server = relationship(Server, primaryjoin=_public_server_id == Server.id)
|
||||
_api_server_id = Column(Integer, ForeignKey('servers.id'))
|
||||
api_server = relationship(Server, primaryjoin=_api_server_id == Server.id)
|
||||
|
||||
|
||||
def __repr__(self):
|
||||
if self.public_server:
|
||||
return "<%s %s:%s>" % (
|
||||
@@ -155,7 +135,7 @@ class Proxy(Base):
|
||||
)
|
||||
else:
|
||||
return "<%s [unconfigured]>" % self.__class__.__name__
|
||||
|
||||
|
||||
def api_request(self, path, method='GET', body=None, client=None):
|
||||
"""Make an authenticated API request of the proxy"""
|
||||
client = client or AsyncHTTPClient()
|
||||
@@ -172,14 +152,47 @@ class Proxy(Base):
|
||||
|
||||
return client.fetch(req)
|
||||
|
||||
@gen.coroutine
|
||||
def add_service(self, service, client=None):
|
||||
"""Add a service's server to the proxy table."""
|
||||
if not service.server:
|
||||
raise RuntimeError(
|
||||
"Service %s does not have an http endpoint to add to the proxy.", service.name)
|
||||
|
||||
self.log.info("Adding service %s to proxy %s => %s",
|
||||
service.name, service.proxy_path, service.server.host,
|
||||
)
|
||||
|
||||
yield self.api_request(service.proxy_path,
|
||||
method='POST',
|
||||
body=dict(
|
||||
target=service.server.host,
|
||||
service=service.name,
|
||||
),
|
||||
client=client,
|
||||
)
|
||||
|
||||
@gen.coroutine
|
||||
def delete_service(self, service, client=None):
|
||||
"""Remove a service's server from the proxy table."""
|
||||
self.log.info("Removing service %s from proxy", service.name)
|
||||
yield self.api_request(service.proxy_path,
|
||||
method='DELETE',
|
||||
client=client,
|
||||
)
|
||||
|
||||
@gen.coroutine
|
||||
def add_user(self, user, client=None):
|
||||
"""Add a user's server to the proxy table."""
|
||||
self.log.info("Adding user %s to proxy %s => %s",
|
||||
user.name, user.server.base_url, user.server.host,
|
||||
user.name, user.proxy_path, user.server.host,
|
||||
)
|
||||
|
||||
yield self.api_request(user.server.base_url,
|
||||
|
||||
if user.spawn_pending:
|
||||
raise RuntimeError(
|
||||
"User %s's spawn is pending, shouldn't be added to the proxy yet!", user.name)
|
||||
|
||||
yield self.api_request(user.proxy_path,
|
||||
method='POST',
|
||||
body=dict(
|
||||
target=user.server.host,
|
||||
@@ -187,26 +200,43 @@ class Proxy(Base):
|
||||
),
|
||||
client=client,
|
||||
)
|
||||
|
||||
|
||||
@gen.coroutine
|
||||
def delete_user(self, user, client=None):
|
||||
"""Remove a user's server to the proxy table."""
|
||||
"""Remove a user's server from the proxy table."""
|
||||
self.log.info("Removing user %s from proxy", user.name)
|
||||
yield self.api_request(user.server.base_url,
|
||||
yield self.api_request(user.proxy_path,
|
||||
method='DELETE',
|
||||
client=client,
|
||||
)
|
||||
|
||||
|
||||
@gen.coroutine
|
||||
def add_all_users(self):
|
||||
def add_all_services(self, service_dict):
|
||||
"""Update the proxy table from the database.
|
||||
|
||||
|
||||
Used when loading up a new proxy.
|
||||
"""
|
||||
db = inspect(self).session
|
||||
futures = []
|
||||
for user in db.query(User):
|
||||
if (user.server):
|
||||
for orm_service in db.query(Service):
|
||||
service = service_dict[orm_service.name]
|
||||
if service.server:
|
||||
futures.append(self.add_service(service))
|
||||
# wait after submitting them all
|
||||
for f in futures:
|
||||
yield f
|
||||
|
||||
@gen.coroutine
|
||||
def add_all_users(self, user_dict):
|
||||
"""Update the proxy table from the database.
|
||||
|
||||
Used when loading up a new proxy.
|
||||
"""
|
||||
db = inspect(self).session
|
||||
futures = []
|
||||
for orm_user in db.query(User):
|
||||
user = user_dict[orm_user]
|
||||
if user.running:
|
||||
futures.append(self.add_user(user))
|
||||
# wait after submitting them all
|
||||
for f in futures:
|
||||
@@ -219,18 +249,38 @@ class Proxy(Base):
|
||||
return json.loads(resp.body.decode('utf8', 'replace'))
|
||||
|
||||
@gen.coroutine
|
||||
def check_routes(self, routes=None):
|
||||
"""Check that all users are properly"""
|
||||
def check_routes(self, user_dict, service_dict, routes=None):
|
||||
"""Check that all users are properly routed on the proxy"""
|
||||
if not routes:
|
||||
routes = yield self.get_routes()
|
||||
|
||||
have_routes = { r['user'] for r in routes.values() if 'user' in r }
|
||||
user_routes = { r['user'] for r in routes.values() if 'user' in r }
|
||||
futures = []
|
||||
db = inspect(self).session
|
||||
for user in db.query(User).filter(User.server != None):
|
||||
if user.name not in have_routes:
|
||||
self.log.warn("Adding missing route for %s", user.name)
|
||||
futures.append(self.add_user(user))
|
||||
for orm_user in db.query(User):
|
||||
user = user_dict[orm_user]
|
||||
if user.running:
|
||||
if user.name not in user_routes:
|
||||
self.log.warning("Adding missing route for %s (%s)", user.name, user.server)
|
||||
futures.append(self.add_user(user))
|
||||
else:
|
||||
# User not running, make sure it's not in the table
|
||||
if user.name in user_routes:
|
||||
self.log.warning("Removing route for not running %s", user.name)
|
||||
futures.append(self.delete_user(user))
|
||||
|
||||
# check service routes
|
||||
service_routes = { r['service'] for r in routes.values() if 'service' in r }
|
||||
for orm_service in db.query(Service).filter(Service.server != None):
|
||||
service = service_dict[orm_service.name]
|
||||
if service.server is None:
|
||||
# This should never be True, but seems to be on rare occasion.
|
||||
# catch filter bug, either in sqlalchemy or my understanding of its behavior
|
||||
self.log.error("Service %s has no server, but wasn't filtered out.", service)
|
||||
continue
|
||||
if service.name not in service_routes:
|
||||
self.log.warning("Adding missing route for %s (%s)", service.name, service.server)
|
||||
futures.append(self.add_service(service))
|
||||
for f in futures:
|
||||
yield f
|
||||
|
||||
@@ -238,9 +288,9 @@ class Proxy(Base):
|
||||
|
||||
class Hub(Base):
|
||||
"""Bring it all together at the hub.
|
||||
|
||||
|
||||
The Hub is a server, plus its API path suffix
|
||||
|
||||
|
||||
the api_url is the full URL plus the api_path suffix on the end
|
||||
of the server base_url.
|
||||
"""
|
||||
@@ -248,12 +298,13 @@ class Hub(Base):
|
||||
id = Column(Integer, primary_key=True)
|
||||
_server_id = Column(Integer, ForeignKey('servers.id'))
|
||||
server = relationship(Server, primaryjoin=_server_id == Server.id)
|
||||
|
||||
host = ''
|
||||
|
||||
@property
|
||||
def api_url(self):
|
||||
"""return the full API url (with proto://host...)"""
|
||||
return url_path_join(self.server.url, 'api')
|
||||
|
||||
|
||||
def __repr__(self):
|
||||
if self.server:
|
||||
return "<%s %s:%s>" % (
|
||||
@@ -263,34 +314,66 @@ class Hub(Base):
|
||||
return "<%s [unconfigured]>" % self.__class__.__name__
|
||||
|
||||
|
||||
# user:group many:many mapping table
|
||||
user_group_map = Table('user_group_map', Base.metadata,
|
||||
Column('user_id', ForeignKey('users.id'), primary_key=True),
|
||||
Column('group_id', ForeignKey('groups.id'), primary_key=True),
|
||||
)
|
||||
|
||||
class Group(Base):
|
||||
"""User Groups"""
|
||||
__tablename__ = 'groups'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(Unicode(1023), unique=True)
|
||||
users = relationship('User', secondary='user_group_map', back_populates='groups')
|
||||
|
||||
def __repr__(self):
|
||||
return "<%s %s (%i users)>" % (
|
||||
self.__class__.__name__, self.name, len(self.users)
|
||||
)
|
||||
@classmethod
|
||||
def find(cls, db, name):
|
||||
"""Find a group by name.
|
||||
|
||||
Returns None if not found.
|
||||
"""
|
||||
return db.query(cls).filter(cls.name==name).first()
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""The User table
|
||||
|
||||
|
||||
Each user has a single server,
|
||||
and multiple tokens used for authorization.
|
||||
|
||||
|
||||
API tokens grant access to the Hub's REST API.
|
||||
These are used by single-user servers to authenticate requests,
|
||||
and external services to manipulate the Hub.
|
||||
|
||||
|
||||
Cookies are set with a single ID.
|
||||
Resetting the Cookie ID invalidates all cookies, forcing user to login again.
|
||||
|
||||
|
||||
A `state` column contains a JSON dict,
|
||||
used for restoring state of a Spawner.
|
||||
"""
|
||||
__tablename__ = 'users'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(Unicode)
|
||||
name = Column(Unicode(1023), unique=True)
|
||||
# should we allow multiple servers per user?
|
||||
_server_id = Column(Integer, ForeignKey('servers.id'))
|
||||
_server_id = Column(Integer, ForeignKey('servers.id', ondelete="SET NULL"))
|
||||
server = relationship(Server, primaryjoin=_server_id == Server.id)
|
||||
admin = Column(Boolean, default=False)
|
||||
last_activity = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
api_tokens = relationship("APIToken", backref="user")
|
||||
cookie_id = Column(Unicode, default=new_token)
|
||||
cookie_id = Column(Unicode(1023), default=new_token)
|
||||
# User.state is actually Spawner state
|
||||
# We will need to figure something else out if/when we have multiple spawners per user
|
||||
state = Column(JSONDict)
|
||||
# Authenticators can store their state here:
|
||||
auth_state = Column(JSONDict)
|
||||
# group mapping
|
||||
groups = relationship('Group', secondary='user_group_map', back_populates='users')
|
||||
|
||||
other_user_cookies = set([])
|
||||
|
||||
@@ -307,17 +390,13 @@ class User(Base):
|
||||
cls=self.__class__.__name__,
|
||||
name=self.name,
|
||||
)
|
||||
|
||||
def new_api_token(self):
|
||||
"""Create a new API token"""
|
||||
assert self.id is not None
|
||||
db = inspect(self).session
|
||||
token = new_token()
|
||||
orm_token = APIToken(user_id=self.id)
|
||||
orm_token.token = token
|
||||
db.add(orm_token)
|
||||
db.commit()
|
||||
return token
|
||||
|
||||
def new_api_token(self, token=None):
|
||||
"""Create a new API token
|
||||
|
||||
If `token` is given, load that token.
|
||||
"""
|
||||
return APIToken.new(token=token, user=self)
|
||||
|
||||
@classmethod
|
||||
def find(cls, db, name):
|
||||
@@ -327,62 +406,163 @@ class User(Base):
|
||||
"""
|
||||
return db.query(cls).filter(cls.name==name).first()
|
||||
|
||||
|
||||
class Service(Base):
|
||||
"""A service run with JupyterHub
|
||||
|
||||
A service is similar to a User without a Spawner.
|
||||
A service can have API tokens for accessing the Hub's API
|
||||
|
||||
It has:
|
||||
|
||||
- name
|
||||
- admin
|
||||
- api tokens
|
||||
- server (if proxied http endpoint)
|
||||
|
||||
In addition to what it has in common with users, a Service has extra info:
|
||||
|
||||
- pid: the process id (if managed)
|
||||
|
||||
"""
|
||||
__tablename__ = 'services'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# common user interface:
|
||||
name = Column(Unicode(1023), unique=True)
|
||||
admin = Column(Boolean, default=False)
|
||||
|
||||
api_tokens = relationship("APIToken", backref="service")
|
||||
|
||||
# service-specific interface
|
||||
_server_id = Column(Integer, ForeignKey('servers.id'))
|
||||
server = relationship(Server, primaryjoin=_server_id == Server.id)
|
||||
pid = Column(Integer)
|
||||
|
||||
def new_api_token(self, token=None):
|
||||
"""Create a new API token
|
||||
|
||||
If `token` is given, load that token.
|
||||
"""
|
||||
return APIToken.new(token=token, service=self)
|
||||
|
||||
@classmethod
|
||||
def find(cls, db, name):
|
||||
"""Find a service by name.
|
||||
|
||||
Returns None if not found.
|
||||
"""
|
||||
return db.query(cls).filter(cls.name==name).first()
|
||||
|
||||
|
||||
class APIToken(Base):
|
||||
"""An API token"""
|
||||
__tablename__ = 'api_tokens'
|
||||
|
||||
# _constraint = ForeignKeyConstraint(['user_id', 'server_id'], ['users.id', 'services.id'])
|
||||
@declared_attr
|
||||
def user_id(cls):
|
||||
return Column(Integer, ForeignKey('users.id'))
|
||||
return Column(Integer, ForeignKey('users.id', ondelete="CASCADE"), nullable=True)
|
||||
|
||||
@declared_attr
|
||||
def service_id(cls):
|
||||
return Column(Integer, ForeignKey('services.id', ondelete="CASCADE"), nullable=True)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
hashed = Column(Unicode)
|
||||
prefix = Column(Unicode)
|
||||
hashed = Column(Unicode(1023))
|
||||
prefix = Column(Unicode(1023))
|
||||
prefix_length = 4
|
||||
algorithm = "sha512"
|
||||
rounds = 16384
|
||||
salt_bytes = 8
|
||||
|
||||
|
||||
@property
|
||||
def token(self):
|
||||
raise AttributeError("token is write-only")
|
||||
|
||||
|
||||
@token.setter
|
||||
def token(self, token):
|
||||
"""Store the hashed value and prefix for a token"""
|
||||
self.prefix = token[:self.prefix_length]
|
||||
self.hashed = hash_token(token, rounds=self.rounds, salt=self.salt_bytes, algorithm=self.algorithm)
|
||||
|
||||
|
||||
def __repr__(self):
|
||||
return "<{cls}('{pre}...', user='{u}')>".format(
|
||||
if self.user is not None:
|
||||
kind = 'user'
|
||||
name = self.user.name
|
||||
elif self.service is not None:
|
||||
kind = 'service'
|
||||
name = self.service.name
|
||||
else:
|
||||
# this shouldn't happen
|
||||
kind = 'owner'
|
||||
name = 'unknown'
|
||||
return "<{cls}('{pre}...', {kind}='{name}')>".format(
|
||||
cls=self.__class__.__name__,
|
||||
pre=self.prefix,
|
||||
u=self.user.name,
|
||||
kind=kind,
|
||||
name=name,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def find(cls, db, token):
|
||||
def find(cls, db, token, *, kind=None):
|
||||
"""Find a token object by value.
|
||||
|
||||
Returns None if not found.
|
||||
|
||||
`kind='user'` only returns API tokens for users
|
||||
`kind='service'` only returns API tokens for services
|
||||
"""
|
||||
prefix = token[:cls.prefix_length]
|
||||
# since we can't filter on hashed values, filter on prefix
|
||||
# so we aren't comparing with all tokens
|
||||
prefix_match = db.query(cls).filter(bindparam('prefix', prefix).startswith(cls.prefix))
|
||||
if kind == 'user':
|
||||
prefix_match = prefix_match.filter(cls.user_id != None)
|
||||
elif kind == 'service':
|
||||
prefix_match = prefix_match.filter(cls.service_id != None)
|
||||
elif kind is not None:
|
||||
raise ValueError("kind must be 'user', 'service', or None, not %r" % kind)
|
||||
for orm_token in prefix_match:
|
||||
if orm_token.match(token):
|
||||
return orm_token
|
||||
|
||||
|
||||
def match(self, token):
|
||||
"""Is this my token?"""
|
||||
return compare_token(self.hashed, token)
|
||||
|
||||
@classmethod
|
||||
def new(cls, token=None, user=None, service=None):
|
||||
"""Generate a new API token for a user or service"""
|
||||
assert user or service
|
||||
assert not (user and service)
|
||||
db = inspect(user or service).session
|
||||
if token is None:
|
||||
token = new_token()
|
||||
else:
|
||||
if len(token) < 8:
|
||||
raise ValueError("Tokens must be at least 8 characters, got %r" % token)
|
||||
found = APIToken.find(db, token)
|
||||
if found:
|
||||
raise ValueError("Collision on token: %s..." % token[:4])
|
||||
orm_token = APIToken(token=token)
|
||||
if user:
|
||||
assert user.id is not None
|
||||
orm_token.user_id = user.id
|
||||
else:
|
||||
assert service.id is not None
|
||||
orm_token.service_id = service.id
|
||||
db.add(orm_token)
|
||||
db.commit()
|
||||
return token
|
||||
|
||||
|
||||
def new_session_factory(url="sqlite:///:memory:", reset=False, **kwargs):
|
||||
"""Create a new session at url"""
|
||||
if url.startswith('sqlite'):
|
||||
kwargs.setdefault('connect_args', {'check_same_thread': False})
|
||||
elif url.startswith('mysql'):
|
||||
kwargs.setdefault('pool_recycle', 60)
|
||||
|
||||
if url.endswith(':memory:'):
|
||||
# If we're using an in-memory database, ensure that only one connection
|
||||
|
0
jupyterhub/services/__init__.py
Normal file
0
jupyterhub/services/__init__.py
Normal file
301
jupyterhub/services/auth.py
Normal file
301
jupyterhub/services/auth.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""Authenticating services with JupyterHub
|
||||
|
||||
Cookies are sent to the Hub for verification, replying with a JSON model describing the authenticated user.
|
||||
|
||||
HubAuth can be used in any application, even outside tornado.
|
||||
|
||||
HubAuthenticated is a mixin class for tornado handlers that should authenticate with the Hub.
|
||||
"""
|
||||
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
|
||||
from tornado.log import app_log
|
||||
from tornado.web import HTTPError
|
||||
|
||||
from traitlets.config import Configurable
|
||||
from traitlets import Unicode, Integer, Instance, default
|
||||
|
||||
from ..utils import url_path_join
|
||||
|
||||
class _ExpiringDict(dict):
|
||||
"""Dict-like cache for Hub API requests
|
||||
|
||||
Values will expire after max_age seconds.
|
||||
|
||||
A monotonic timer is used (time.monotonic).
|
||||
|
||||
A max_age of 0 means cache forever.
|
||||
"""
|
||||
|
||||
max_age = 0
|
||||
|
||||
def __init__(self, max_age=0):
|
||||
self.max_age = max_age
|
||||
self.timestamps = {}
|
||||
self.values = {}
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
"""Store key and record timestamp"""
|
||||
self.timestamps[key] = time.monotonic()
|
||||
self.values[key] = value
|
||||
|
||||
def _check_age(self, key):
|
||||
"""Check timestamp for a key"""
|
||||
if key not in self.values:
|
||||
# not registered, nothing to do
|
||||
return
|
||||
now = time.monotonic()
|
||||
timestamp = self.timestamps[key]
|
||||
if self.max_age > 0 and timestamp + self.max_age < now:
|
||||
self.values.pop(key)
|
||||
self.timestamps.pop(key)
|
||||
|
||||
def __contains__(self, key):
|
||||
"""dict check for `key in dict`"""
|
||||
self._check_age(key)
|
||||
return key in self.values
|
||||
|
||||
def __getitem__(self, key):
|
||||
"""Check age before returning value"""
|
||||
self._check_age(key)
|
||||
return self.values[key]
|
||||
|
||||
def get(self, key, default=None):
|
||||
"""dict-like get:"""
|
||||
try:
|
||||
return self[key]
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
|
||||
class HubAuth(Configurable):
|
||||
"""A class for authenticating with JupyterHub
|
||||
|
||||
This can be used by any application.
|
||||
|
||||
If using tornado, use via :class:`HubAuthenticated` mixin.
|
||||
If using manually, use the ``.user_for_cookie(cookie_value)`` method
|
||||
to identify the user corresponding to a given cookie value.
|
||||
|
||||
The following config must be set:
|
||||
|
||||
- api_token (token for authenticating with JupyterHub API),
|
||||
fetched from the JUPYTERHUB_API_TOKEN env by default.
|
||||
|
||||
The following config MAY be set:
|
||||
|
||||
- api_url: the base URL of the Hub's internal API,
|
||||
fetched from JUPYTERHUB_API_URL by default.
|
||||
- cookie_cache_max_age: the number of seconds responses
|
||||
from the Hub should be cached.
|
||||
- login_url (the *public* ``/hub/login`` URL of the Hub).
|
||||
- cookie_name: the name of the cookie I should be using,
|
||||
if different from the default (unlikely).
|
||||
|
||||
"""
|
||||
|
||||
# where is the hub
|
||||
api_url = Unicode(os.environ.get('JUPYTERHUB_API_URL') or 'http://127.0.0.1:8081/hub/api',
|
||||
help="""The base API URL of the Hub.
|
||||
|
||||
Typically http://hub-ip:hub-port/hub/api
|
||||
"""
|
||||
).tag(config=True)
|
||||
|
||||
login_url = Unicode('/hub/login',
|
||||
help="""The login URL of the Hub
|
||||
|
||||
Typically /hub/login
|
||||
"""
|
||||
).tag(config=True)
|
||||
|
||||
api_token = Unicode(os.environ.get('JUPYTERHUB_API_TOKEN', ''),
|
||||
help="""API key for accessing Hub API.
|
||||
|
||||
Generate with `jupyterhub token [username]` or add to JupyterHub.services config.
|
||||
"""
|
||||
).tag(config=True)
|
||||
|
||||
cookie_name = Unicode('jupyterhub-services',
|
||||
help="""The name of the cookie I should be looking for"""
|
||||
).tag(config=True)
|
||||
cookie_cache_max_age = Integer(300,
|
||||
help="""The maximum time (in seconds) to cache the Hub's response for cookie authentication.
|
||||
|
||||
A larger value reduces load on the Hub and occasional response lag.
|
||||
A smaller value reduces propagation time of changes on the Hub (rare).
|
||||
|
||||
Default: 300 (five minutes)
|
||||
"""
|
||||
).tag(config=True)
|
||||
cookie_cache = Instance(_ExpiringDict, allow_none=False)
|
||||
@default('cookie_cache')
|
||||
def _cookie_cache(self):
|
||||
return _ExpiringDict(self.cookie_cache_max_age)
|
||||
|
||||
def user_for_cookie(self, encrypted_cookie, use_cache=True):
|
||||
"""Ask the Hub to identify the user for a given cookie.
|
||||
|
||||
Args:
|
||||
encrypted_cookie (str): the cookie value (not decrypted, the Hub will do that)
|
||||
use_cache (bool): Specify use_cache=False to skip cached cookie values (default: True)
|
||||
|
||||
Returns:
|
||||
user_model (dict): The user model, if a user is identified, None if authentication fails.
|
||||
|
||||
The 'name' field contains the user's name.
|
||||
"""
|
||||
if use_cache:
|
||||
cached = self.cookie_cache.get(encrypted_cookie)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
r = requests.get(
|
||||
url_path_join(self.api_url,
|
||||
"authorizations/cookie",
|
||||
self.cookie_name,
|
||||
quote(encrypted_cookie, safe=''),
|
||||
),
|
||||
headers = {
|
||||
'Authorization' : 'token %s' % self.api_token,
|
||||
},
|
||||
)
|
||||
except requests.ConnectionError:
|
||||
msg = "Failed to connect to Hub API at %r." % self.api_url
|
||||
msg += " Is the Hub accessible at this URL (from host: %s)?" % socket.gethostname()
|
||||
if '127.0.0.1' in self.api_url:
|
||||
msg += " Make sure to set c.JupyterHub.hub_ip to an IP accessible to" + \
|
||||
" single-user servers if the servers are not on the same host as the Hub."
|
||||
raise HTTPError(500, msg)
|
||||
|
||||
if r.status_code == 404:
|
||||
data = None
|
||||
elif r.status_code == 403:
|
||||
app_log.error("I don't have permission to verify cookies, my auth token may have expired: [%i] %s", r.status_code, r.reason)
|
||||
raise HTTPError(500, "Permission failure checking authorization, I may need a new token")
|
||||
elif r.status_code >= 500:
|
||||
app_log.error("Upstream failure verifying auth token: [%i] %s", r.status_code, r.reason)
|
||||
raise HTTPError(502, "Failed to check authorization (upstream problem)")
|
||||
elif r.status_code >= 400:
|
||||
app_log.warning("Failed to check authorization: [%i] %s", r.status_code, r.reason)
|
||||
raise HTTPError(500, "Failed to check authorization")
|
||||
else:
|
||||
data = r.json()
|
||||
self.cookie_cache[encrypted_cookie] = data
|
||||
return data
|
||||
|
||||
def get_user(self, handler):
|
||||
"""Get the Hub user for a given tornado handler.
|
||||
|
||||
Checks cookie with the Hub to identify the current user.
|
||||
|
||||
Args:
|
||||
handler (tornado.web.RequestHandler): the current request handler
|
||||
|
||||
Returns:
|
||||
user_model (dict): The user model, if a user is identified, None if authentication fails.
|
||||
|
||||
The 'name' field contains the user's name.
|
||||
"""
|
||||
|
||||
# only allow this to be called once per handler
|
||||
# avoids issues if an error is raised,
|
||||
# since this may be called again when trying to render the error page
|
||||
if hasattr(handler, '_cached_hub_user'):
|
||||
return handler._cached_hub_user
|
||||
|
||||
handler._cached_hub_user = None
|
||||
encrypted_cookie = handler.get_cookie(self.cookie_name)
|
||||
if encrypted_cookie:
|
||||
user_model = self.user_for_cookie(encrypted_cookie)
|
||||
handler._cached_hub_user = user_model
|
||||
return user_model
|
||||
else:
|
||||
app_log.debug("No token cookie")
|
||||
return None
|
||||
|
||||
|
||||
class HubAuthenticated(object):
|
||||
"""Mixin for tornado handlers that are authenticated with JupyterHub
|
||||
|
||||
A handler that mixes this in must have the following attributes/properties:
|
||||
|
||||
- .hub_auth: A HubAuth instance
|
||||
- .hub_users: A set of usernames to allow.
|
||||
If left unspecified or None, username will not be checked.
|
||||
- .hub_groups: A set of group names to allow.
|
||||
If left unspecified or None, groups will not be checked.
|
||||
|
||||
Examples::
|
||||
|
||||
class MyHandler(HubAuthenticated, web.RequestHandler):
|
||||
hub_users = {'inara', 'mal'}
|
||||
|
||||
def initialize(self, hub_auth):
|
||||
self.hub_auth = hub_auth
|
||||
|
||||
@web.authenticated
|
||||
def get(self):
|
||||
...
|
||||
|
||||
"""
|
||||
hub_users = None # set of allowed users
|
||||
hub_groups = None # set of allowed groups
|
||||
|
||||
# self.hub_auth must be a HubAuth instance.
|
||||
# If nothing specified, use default config,
|
||||
# which will be configured with defaults
|
||||
# based on JupyterHub environment variables for services.
|
||||
_hub_auth = None
|
||||
@property
|
||||
def hub_auth(self):
|
||||
if self._hub_auth is None:
|
||||
self._hub_auth = HubAuth()
|
||||
return self._hub_auth
|
||||
|
||||
@hub_auth.setter
|
||||
def hub_auth(self, auth):
|
||||
self._hub_auth = auth
|
||||
|
||||
def check_hub_user(self, user_model):
|
||||
"""Check whether Hub-authenticated user should be allowed.
|
||||
|
||||
Returns the input if the user should be allowed, None otherwise.
|
||||
|
||||
Override if you want to check anything other than the username's presence in hub_users list.
|
||||
|
||||
Args:
|
||||
user_model (dict): the user model returned from :class:`HubAuth`
|
||||
Returns:
|
||||
user_model (dict): The user model if the user should be allowed, None otherwise.
|
||||
"""
|
||||
if self.hub_users is None and self.hub_groups is None:
|
||||
# no whitelist specified, allow any authenticated Hub user
|
||||
return user_model
|
||||
name = user_model['name']
|
||||
if self.hub_users and name in self.hub_users:
|
||||
# user in whitelist
|
||||
return user_model
|
||||
elif self.hub_groups and set(user_model['groups']).union(self.hub_groups):
|
||||
# group in whitelist
|
||||
return user_model
|
||||
else:
|
||||
app_log.warning("Not allowing Hub user %s" % name)
|
||||
return None
|
||||
|
||||
def get_current_user(self):
|
||||
"""Tornado's authentication method
|
||||
|
||||
Returns:
|
||||
user_model (dict): The user model, if a user is identified, None if authentication fails.
|
||||
"""
|
||||
user_model = self.hub_auth.get_user(self)
|
||||
if not user_model:
|
||||
return
|
||||
return self.check_hub_user(user_model)
|
||||
|
258
jupyterhub/services/service.py
Normal file
258
jupyterhub/services/service.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""A service is a process that talks to JupyterHub
|
||||
|
||||
Cases:
|
||||
|
||||
Managed:
|
||||
- managed by JupyterHub (always subprocess, no custom Spawners)
|
||||
- always a long-running process
|
||||
- managed services are restarted automatically if they exit unexpectedly
|
||||
Unmanaged:
|
||||
- managed by external service (docker, systemd, etc.)
|
||||
- do not need to be long-running processes, or processes at all
|
||||
|
||||
|
||||
URL: needs a route added to the proxy.
|
||||
- Public route will always be /services/service-name
|
||||
- url specified in config
|
||||
- if port is 0, Hub will select a port
|
||||
|
||||
API access:
|
||||
- admin: tokens will have admin-access to the API
|
||||
- not admin: tokens will only have non-admin access
|
||||
(not much they can do other than defer to Hub for auth)
|
||||
|
||||
An externally managed service running on a URL::
|
||||
|
||||
{
|
||||
'name': 'my-service',
|
||||
'url': 'https://host:8888',
|
||||
'admin': True,
|
||||
'token': 'super-secret',
|
||||
}
|
||||
|
||||
A hub-managed service with no URL:
|
||||
|
||||
{
|
||||
'name': 'cull-idle',
|
||||
'command': ['python', '/path/to/cull-idle']
|
||||
'admin': True,
|
||||
}
|
||||
"""
|
||||
|
||||
from getpass import getuser
|
||||
import pipes
|
||||
import shutil
|
||||
from subprocess import Popen
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from tornado import gen
|
||||
|
||||
from traitlets import (
|
||||
HasTraits,
|
||||
Any, Bool, Dict, Unicode, Instance,
|
||||
default, observe,
|
||||
)
|
||||
from traitlets.config import LoggingConfigurable
|
||||
|
||||
from .. import orm
|
||||
from ..traitlets import Command
|
||||
from ..spawner import LocalProcessSpawner
|
||||
from ..utils import url_path_join
|
||||
|
||||
class _MockUser(HasTraits):
|
||||
name = Unicode()
|
||||
server = Instance(orm.Server, allow_none=True)
|
||||
state = Dict()
|
||||
service = Instance(__module__ + '.Service')
|
||||
|
||||
# We probably shouldn't use a Spawner here,
|
||||
# but there are too many concepts to share.
|
||||
|
||||
class _ServiceSpawner(LocalProcessSpawner):
|
||||
"""Subclass of LocalProcessSpawner
|
||||
|
||||
Removes notebook-specific-ness from LocalProcessSpawner.
|
||||
"""
|
||||
cwd = Unicode()
|
||||
cmd = Command(minlen=0)
|
||||
|
||||
def make_preexec_fn(self, name):
|
||||
if not name or name == getuser():
|
||||
# no setuid if no name
|
||||
return
|
||||
return super().make_preexec_fn(name)
|
||||
|
||||
def start(self):
|
||||
"""Start the process"""
|
||||
env = self.get_env()
|
||||
cmd = self.cmd
|
||||
|
||||
self.log.info("Spawning %s", ' '.join(pipes.quote(s) for s in cmd))
|
||||
try:
|
||||
self.proc = Popen(self.cmd, env=env,
|
||||
preexec_fn=self.make_preexec_fn(self.user.name),
|
||||
start_new_session=True, # don't forward signals
|
||||
cwd=self.cwd or None,
|
||||
)
|
||||
except PermissionError:
|
||||
# use which to get abspath
|
||||
script = shutil.which(cmd[0]) or cmd[0]
|
||||
self.log.error("Permission denied trying to run %r. Does %s have access to this file?",
|
||||
script, self.user.name,
|
||||
)
|
||||
raise
|
||||
|
||||
self.pid = self.proc.pid
|
||||
|
||||
class Service(LoggingConfigurable):
|
||||
"""An object wrapping a service specification for Hub API consumers.
|
||||
|
||||
A service has inputs:
|
||||
|
||||
- name: str
|
||||
the name of the service
|
||||
- admin: bool(false)
|
||||
whether the service should have administrative privileges
|
||||
- url: str (None)
|
||||
The URL where the service is/should be.
|
||||
If specified, the service will be added to the proxy at /services/:name
|
||||
|
||||
If a service is to be managed by the Hub, it has a few extra options:
|
||||
|
||||
- command: (str/Popen list)
|
||||
Command for JupyterHub to spawn the service.
|
||||
Only use this if the service should be a subprocess.
|
||||
If command is not specified, it is assumed to be managed
|
||||
by a
|
||||
- environment: dict
|
||||
Additional environment variables for the service.
|
||||
- user: str
|
||||
The name of a system user to become.
|
||||
If unspecified, run as the same user as the Hub.
|
||||
"""
|
||||
|
||||
# inputs:
|
||||
name = Unicode(
|
||||
help="""The name of the service.
|
||||
|
||||
If the service has an http endpoint, it
|
||||
"""
|
||||
).tag(input=True)
|
||||
admin = Bool(False,
|
||||
help="Does the service need admin-access to the Hub API?"
|
||||
).tag(input=True)
|
||||
url = Unicode(
|
||||
help="""URL of the service.
|
||||
|
||||
Only specify if the service runs an HTTP(s) endpoint that.
|
||||
If managed, will be passed as JUPYTERHUB_SERVICE_URL env.
|
||||
"""
|
||||
).tag(input=True)
|
||||
api_token = Unicode(
|
||||
help="""The API token to use for the service.
|
||||
|
||||
If unspecified, an API token will be generated for managed services.
|
||||
"""
|
||||
).tag(input=True)
|
||||
# Managed service API:
|
||||
|
||||
@property
|
||||
def managed(self):
|
||||
"""Am I managed by the Hub?"""
|
||||
return bool(self.command)
|
||||
|
||||
command = Command(minlen=0,
|
||||
help="Command to spawn this service, if managed."
|
||||
).tag(input=True)
|
||||
cwd = Unicode(
|
||||
help="""The working directory in which to run the service."""
|
||||
).tag(input=True)
|
||||
environment = Dict(
|
||||
help="""Environment variables to pass to the service.
|
||||
Only used if the Hub is spawning the service.
|
||||
"""
|
||||
).tag(input=True)
|
||||
user = Unicode(getuser(),
|
||||
help="""The user to become when launching the service.
|
||||
|
||||
If unspecified, run the service as the same user as the Hub.
|
||||
"""
|
||||
).tag(input=True)
|
||||
|
||||
domain = Unicode()
|
||||
host = Unicode()
|
||||
proc = Any()
|
||||
|
||||
# handles on globals:
|
||||
proxy = Any()
|
||||
base_url = Unicode()
|
||||
db = Any()
|
||||
orm = Any()
|
||||
|
||||
@property
|
||||
def server(self):
|
||||
return self.orm.server
|
||||
|
||||
@property
|
||||
def prefix(self):
|
||||
return url_path_join(self.base_url, 'services', self.name)
|
||||
|
||||
@property
|
||||
def proxy_path(self):
|
||||
if not self.server:
|
||||
return ''
|
||||
if self.domain:
|
||||
return url_path_join('/' + self.domain, self.server.base_url)
|
||||
else:
|
||||
return self.server.base_url
|
||||
|
||||
def __repr__(self):
|
||||
return "<{cls}(name={name}{managed})>".format(
|
||||
cls=self.__class__.__name__,
|
||||
name=self.name,
|
||||
managed=' managed' if self.managed else '',
|
||||
)
|
||||
|
||||
def start(self):
|
||||
"""Start a managed service"""
|
||||
if not self.managed:
|
||||
raise RuntimeError("Cannot start unmanaged service %s" % self)
|
||||
self.log.info("Starting service %r: %r", self.name, self.command)
|
||||
env = {}
|
||||
env.update(self.environment)
|
||||
|
||||
env['JUPYTERHUB_SERVICE_NAME'] = self.name
|
||||
env['JUPYTERHUB_API_TOKEN'] = self.api_token
|
||||
env['JUPYTERHUB_API_URL'] = self.hub_api_url
|
||||
env['JUPYTERHUB_BASE_URL'] = self.base_url
|
||||
if self.url:
|
||||
env['JUPYTERHUB_SERVICE_URL'] = self.url
|
||||
env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url
|
||||
|
||||
self.spawner = _ServiceSpawner(
|
||||
cmd=self.command,
|
||||
environment=env,
|
||||
api_token=self.api_token,
|
||||
cwd=self.cwd,
|
||||
user=_MockUser(
|
||||
name=self.user,
|
||||
service=self,
|
||||
server=self.orm.server,
|
||||
),
|
||||
)
|
||||
self.spawner.start()
|
||||
self.proc = self.spawner.proc
|
||||
self.spawner.add_poll_callback(self._proc_stopped)
|
||||
self.spawner.start_polling()
|
||||
|
||||
def _proc_stopped(self):
|
||||
"""Called when the service process unexpectedly exits"""
|
||||
self.log.error("Service %s exited with status %i", self.name, self.proc.returncode)
|
||||
self.start()
|
||||
|
||||
def stop(self):
|
||||
"""Stop a managed service"""
|
||||
if not self.managed:
|
||||
raise RuntimeError("Cannot start unmanaged service %s" % self)
|
||||
self.spawner.stop_polling()
|
||||
return self.spawner.stop()
|
273
jupyterhub/singleuser.py
Normal file
273
jupyterhub/singleuser.py
Normal file
@@ -0,0 +1,273 @@
|
||||
#!/usr/bin/env python
|
||||
"""Extend regular notebook server to be aware of multiuser things."""
|
||||
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
import os
|
||||
|
||||
from jinja2 import ChoiceLoader, FunctionLoader
|
||||
|
||||
from tornado import ioloop
|
||||
from textwrap import dedent
|
||||
|
||||
try:
|
||||
import notebook
|
||||
except ImportError:
|
||||
raise ImportError("JupyterHub single-user server requires notebook >= 4.0")
|
||||
|
||||
from traitlets import (
|
||||
Bool,
|
||||
Unicode,
|
||||
CUnicode,
|
||||
default,
|
||||
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 jupyterhub import __version__
|
||||
from .services.auth import HubAuth, HubAuthenticated
|
||||
from .utils import url_path_join
|
||||
|
||||
# Authenticate requests with the Hub
|
||||
|
||||
class HubAuthenticatedHandler(HubAuthenticated):
|
||||
"""Class we are going to patch-in for authentication with the Hub"""
|
||||
@property
|
||||
def hub_auth(self):
|
||||
return self.settings['hub_auth']
|
||||
@property
|
||||
def hub_users(self):
|
||||
return { self.settings['user'] }
|
||||
|
||||
|
||||
class JupyterHubLoginHandler(LoginHandler):
|
||||
"""LoginHandler that hooks up Hub authentication"""
|
||||
@staticmethod
|
||||
def login_available(settings):
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def get_user(handler):
|
||||
"""alternative get_current_user to query the Hub"""
|
||||
# patch in HubAuthenticated class for querying the Hub for cookie authentication
|
||||
name = 'NowHubAuthenticated'
|
||||
if handler.__class__.__name__ != name:
|
||||
handler.__class__ = type(name, (HubAuthenticatedHandler, handler.__class__), {})
|
||||
return handler.get_current_user()
|
||||
|
||||
|
||||
class JupyterHubLogoutHandler(LogoutHandler):
|
||||
def get(self):
|
||||
self.redirect(
|
||||
self.settings['hub_host'] +
|
||||
url_path_join(self.settings['hub_prefix'], 'logout'))
|
||||
|
||||
|
||||
# register new hub related command-line aliases
|
||||
aliases = dict(notebook_aliases)
|
||||
aliases.update({
|
||||
'user' : 'SingleUserNotebookApp.user',
|
||||
'cookie-name': 'HubAuth.cookie_name',
|
||||
'hub-prefix': 'SingleUserNotebookApp.hub_prefix',
|
||||
'hub-host': 'SingleUserNotebookApp.hub_host',
|
||||
'hub-api-url': 'SingleUserNotebookApp.hub_api_url',
|
||||
'base-url': 'SingleUserNotebookApp.base_url',
|
||||
})
|
||||
flags = dict(notebook_flags)
|
||||
flags.update({
|
||||
'disable-user-config': ({
|
||||
'SingleUserNotebookApp': {
|
||||
'disable_user_config': True
|
||||
}
|
||||
}, "Disable user-controlled configuration of the notebook server.")
|
||||
})
|
||||
|
||||
page_template = """
|
||||
{% extends "templates/page.html" %}
|
||||
|
||||
{% block header_buttons %}
|
||||
{{super()}}
|
||||
|
||||
<a href='{{hub_control_panel_url}}'
|
||||
class='btn btn-default btn-sm navbar-btn pull-right'
|
||||
style='margin-right: 4px; margin-left: 2px;'
|
||||
>
|
||||
Control Panel</a>
|
||||
{% endblock %}
|
||||
{% block logo %}
|
||||
<img src='{{logo_url}}' alt='Jupyter Notebook'/>
|
||||
{% endblock logo %}
|
||||
"""
|
||||
|
||||
def _exclude_home(path_list):
|
||||
"""Filter out any entries in a path list that are in my home directory.
|
||||
|
||||
Used to disable per-user configuration.
|
||||
"""
|
||||
home = os.path.expanduser('~')
|
||||
for p in path_list:
|
||||
if not p.startswith(home):
|
||||
yield p
|
||||
|
||||
class SingleUserNotebookApp(NotebookApp):
|
||||
"""A Subclass of the regular NotebookApp that is aware of the parent multiuser context."""
|
||||
description = dedent("""
|
||||
Single-user server for JupyterHub. Extends the Jupyter Notebook server.
|
||||
|
||||
Meant to be invoked by JupyterHub Spawners, and not directly.
|
||||
""")
|
||||
|
||||
examples = ""
|
||||
subcommands = {}
|
||||
version = __version__
|
||||
classes = NotebookApp.classes + [HubAuth]
|
||||
|
||||
user = CUnicode(config=True)
|
||||
def _user_changed(self, name, old, new):
|
||||
self.log.name = new
|
||||
hub_prefix = Unicode().tag(config=True)
|
||||
hub_host = Unicode().tag(config=True)
|
||||
hub_api_url = Unicode().tag(config=True)
|
||||
aliases = aliases
|
||||
flags = flags
|
||||
|
||||
# disble some single-user configurables
|
||||
token = ''
|
||||
open_browser = False
|
||||
trust_xheaders = True
|
||||
login_handler_class = JupyterHubLoginHandler
|
||||
logout_handler_class = JupyterHubLogoutHandler
|
||||
port_retries = 0 # disable port-retries, since the Spawner will tell us what port to use
|
||||
|
||||
disable_user_config = Bool(False,
|
||||
help="""Disable user configuration of single-user server.
|
||||
|
||||
Prevents user-writable files that normally configure the single-user server
|
||||
from being loaded, ensuring admins have full control of configuration.
|
||||
"""
|
||||
).tag(config=True)
|
||||
|
||||
@validate('notebook_dir')
|
||||
def _notebook_dir_validate(self, proposal):
|
||||
value = os.path.expanduser(proposal['value'])
|
||||
# Strip any trailing slashes
|
||||
# *except* if it's root
|
||||
_, path = os.path.splitdrive(value)
|
||||
if path == os.sep:
|
||||
return value
|
||||
value = value.rstrip(os.sep)
|
||||
if not os.path.isabs(value):
|
||||
# If we receive a non-absolute path, make it absolute.
|
||||
value = os.path.abspath(value)
|
||||
if not os.path.isdir(value):
|
||||
raise TraitError("No such notebook dir: %r" % value)
|
||||
return value
|
||||
|
||||
@default('log_datefmt')
|
||||
def _log_datefmt_default(self):
|
||||
"""Exclude date from default date format"""
|
||||
return "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
@default('log_format')
|
||||
def _log_format_default(self):
|
||||
"""override default log format to include time"""
|
||||
return "%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s %(module)s:%(lineno)d]%(end_color)s %(message)s"
|
||||
|
||||
def _confirm_exit(self):
|
||||
# disable the exit confirmation for background notebook processes
|
||||
ioloop.IOLoop.instance().stop()
|
||||
|
||||
def migrate_config(self):
|
||||
if self.disable_user_config:
|
||||
# disable config-migration when user config is disabled
|
||||
return
|
||||
else:
|
||||
super(SingleUserNotebookApp, self).migrate_config()
|
||||
|
||||
@property
|
||||
def config_file_paths(self):
|
||||
path = super(SingleUserNotebookApp, self).config_file_paths
|
||||
|
||||
if self.disable_user_config:
|
||||
# filter out user-writable config dirs if user config is disabled
|
||||
path = list(_exclude_home(path))
|
||||
return path
|
||||
|
||||
@property
|
||||
def nbextensions_path(self):
|
||||
path = super(SingleUserNotebookApp, self).nbextensions_path
|
||||
|
||||
if self.disable_user_config:
|
||||
path = list(_exclude_home(path))
|
||||
return path
|
||||
|
||||
@validate('static_custom_path')
|
||||
def _validate_static_custom_path(self, proposal):
|
||||
path = proposal['value']
|
||||
if self.disable_user_config:
|
||||
path = list(_exclude_home(path))
|
||||
return path
|
||||
|
||||
def start(self):
|
||||
super(SingleUserNotebookApp, self).start()
|
||||
|
||||
def init_hub_auth(self):
|
||||
if not os.environ.get('JPY_API_TOKEN'):
|
||||
self.exit("JPY_API_TOKEN env is required to run jupyterhub-singleuser. Did you launch it manually?")
|
||||
self.hub_auth = HubAuth(
|
||||
parent=self,
|
||||
api_token=os.environ.pop('JPY_API_TOKEN'),
|
||||
api_url=self.hub_api_url,
|
||||
)
|
||||
|
||||
def init_webapp(self):
|
||||
# load the hub related settings into the tornado settings dict
|
||||
self.init_hub_auth()
|
||||
s = self.tornado_settings
|
||||
s['user'] = self.user
|
||||
s['hub_prefix'] = self.hub_prefix
|
||||
s['hub_host'] = self.hub_host
|
||||
s['hub_auth'] = self.hub_auth
|
||||
s['login_url'] = self.hub_host + self.hub_prefix
|
||||
s['csp_report_uri'] = self.hub_host + url_path_join(self.hub_prefix, 'security/csp-report')
|
||||
super(SingleUserNotebookApp, self).init_webapp()
|
||||
self.patch_templates()
|
||||
|
||||
def patch_templates(self):
|
||||
"""Patch page templates to add Hub-related buttons"""
|
||||
|
||||
self.jinja_template_vars['logo_url'] = self.hub_host + url_path_join(self.hub_prefix, 'logo')
|
||||
self.jinja_template_vars['hub_host'] = self.hub_host
|
||||
self.jinja_template_vars['hub_prefix'] = self.hub_prefix
|
||||
env = self.web_app.settings['jinja2_env']
|
||||
|
||||
env.globals['hub_control_panel_url'] = \
|
||||
self.hub_host + url_path_join(self.hub_prefix, 'home')
|
||||
|
||||
# patch jinja env loading to modify page template
|
||||
def get_page(name):
|
||||
if name == 'page.html':
|
||||
return page_template
|
||||
|
||||
orig_loader = env.loader
|
||||
env.loader = ChoiceLoader([
|
||||
FunctionLoader(get_page),
|
||||
orig_loader,
|
||||
])
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
return SingleUserNotebookApp.launch_instance(argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
File diff suppressed because it is too large
Load Diff
@@ -5,14 +5,19 @@
|
||||
|
||||
import logging
|
||||
from getpass import getuser
|
||||
|
||||
from pytest import fixture
|
||||
from subprocess import TimeoutExpired
|
||||
import time
|
||||
from unittest import mock
|
||||
from pytest import fixture, yield_fixture, raises
|
||||
from tornado import ioloop
|
||||
|
||||
from .. import orm
|
||||
from ..utils import random_port
|
||||
|
||||
from .mocking import MockHub
|
||||
from .test_services import mockservice_cmd
|
||||
|
||||
import jupyterhub.services.service
|
||||
|
||||
# global db session object
|
||||
_db = None
|
||||
@@ -53,3 +58,58 @@ def app(request):
|
||||
app.stop()
|
||||
request.addfinalizer(fin)
|
||||
return app
|
||||
|
||||
|
||||
# mock services for testing.
|
||||
# Shorter intervals, etc.
|
||||
class MockServiceSpawner(jupyterhub.services.service._ServiceSpawner):
|
||||
poll_interval = 1
|
||||
|
||||
|
||||
def _mockservice(request, app, url=False):
|
||||
name = 'mock-service'
|
||||
spec = {
|
||||
'name': name,
|
||||
'command': mockservice_cmd,
|
||||
'admin': True,
|
||||
}
|
||||
if url:
|
||||
spec['url'] = 'http://127.0.0.1:%i' % random_port(),
|
||||
|
||||
with mock.patch.object(jupyterhub.services.service, '_ServiceSpawner', MockServiceSpawner):
|
||||
app.services = [{
|
||||
'name': name,
|
||||
'command': mockservice_cmd,
|
||||
'url': 'http://127.0.0.1:%i' % random_port(),
|
||||
'admin': True,
|
||||
}]
|
||||
app.init_services()
|
||||
app.io_loop.add_callback(app.proxy.add_all_services, app._service_map)
|
||||
assert name in app._service_map
|
||||
service = app._service_map[name]
|
||||
app.io_loop.add_callback(service.start)
|
||||
request.addfinalizer(service.stop)
|
||||
for i in range(20):
|
||||
if not getattr(service, 'proc', False):
|
||||
time.sleep(0.2)
|
||||
# ensure process finishes starting
|
||||
with raises(TimeoutExpired):
|
||||
service.proc.wait(1)
|
||||
return service
|
||||
|
||||
@yield_fixture
|
||||
def mockservice(request, app):
|
||||
yield _mockservice(request, app, url=False)
|
||||
|
||||
@yield_fixture
|
||||
def mockservice_url(request, app):
|
||||
yield _mockservice(request, app, url=True)
|
||||
|
||||
@yield_fixture
|
||||
def no_patience(app):
|
||||
"""Set slow-spawning timeouts to zero"""
|
||||
with mock.patch.dict(app.tornado_application.settings,
|
||||
{'slow_spawn_timeout': 0,
|
||||
'slow_stop_timeout': 0}):
|
||||
yield
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"""mock utilities for testing"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import timedelta
|
||||
from tempfile import NamedTemporaryFile
|
||||
import threading
|
||||
|
||||
@@ -13,11 +13,14 @@ from tornado import gen
|
||||
from tornado.concurrent import Future
|
||||
from tornado.ioloop import IOLoop
|
||||
|
||||
from ..spawner import LocalProcessSpawner
|
||||
from traitlets import default
|
||||
|
||||
from ..app import JupyterHub
|
||||
from ..auth import PAMAuthenticator
|
||||
from .. import orm
|
||||
from ..utils import localhost
|
||||
from ..spawner import LocalProcessSpawner
|
||||
from ..singleuser import SingleUserNotebookApp
|
||||
from ..utils import random_port
|
||||
|
||||
from pamela import PAMError
|
||||
|
||||
@@ -34,7 +37,11 @@ def mock_open_session(username, service):
|
||||
|
||||
|
||||
class MockSpawner(LocalProcessSpawner):
|
||||
"""Base mock spawner
|
||||
|
||||
- disables user-switching that we need root permissions to do
|
||||
- spawns jupyterhub.tests.mocksu instead of a full single-user server
|
||||
"""
|
||||
def make_preexec_fn(self, *a, **kw):
|
||||
# skip the setuid stuff
|
||||
return
|
||||
@@ -44,7 +51,8 @@ class MockSpawner(LocalProcessSpawner):
|
||||
|
||||
def user_env(self, env):
|
||||
return env
|
||||
|
||||
|
||||
@default('cmd')
|
||||
def _cmd_default(self):
|
||||
return [sys.executable, '-m', 'jupyterhub.tests.mocksu']
|
||||
|
||||
@@ -54,8 +62,9 @@ class SlowSpawner(MockSpawner):
|
||||
|
||||
@gen.coroutine
|
||||
def start(self):
|
||||
yield super().start()
|
||||
(ip, port) = yield super().start()
|
||||
yield gen.sleep(2)
|
||||
return ip, port
|
||||
|
||||
@gen.coroutine
|
||||
def stop(self):
|
||||
@@ -66,6 +75,7 @@ class SlowSpawner(MockSpawner):
|
||||
class NeverSpawner(MockSpawner):
|
||||
"""A spawner that will never start"""
|
||||
|
||||
@default('start_timeout')
|
||||
def _start_timeout_default(self):
|
||||
return 1
|
||||
|
||||
@@ -75,6 +85,7 @@ class NeverSpawner(MockSpawner):
|
||||
|
||||
|
||||
class FormSpawner(MockSpawner):
|
||||
"""A spawner that has an options form defined"""
|
||||
options_form = "IMAFORM"
|
||||
|
||||
def options_from_form(self, form_data):
|
||||
@@ -90,6 +101,7 @@ class FormSpawner(MockSpawner):
|
||||
|
||||
|
||||
class MockPAMAuthenticator(PAMAuthenticator):
|
||||
@default('admin_users')
|
||||
def _admin_users_default(self):
|
||||
return {'admin'}
|
||||
|
||||
@@ -105,17 +117,29 @@ class MockPAMAuthenticator(PAMAuthenticator):
|
||||
):
|
||||
return super(MockPAMAuthenticator, self).authenticate(*args, **kwargs)
|
||||
|
||||
|
||||
class MockHub(JupyterHub):
|
||||
"""Hub with various mock bits"""
|
||||
|
||||
db_file = None
|
||||
|
||||
def _ip_default(self):
|
||||
return localhost()
|
||||
last_activity_interval = 2
|
||||
|
||||
base_url = '/@/space%20word/'
|
||||
|
||||
@default('subdomain_host')
|
||||
def _subdomain_host_default(self):
|
||||
return os.environ.get('JUPYTERHUB_TEST_SUBDOMAIN_HOST', '')
|
||||
|
||||
@default('ip')
|
||||
def _ip_default(self):
|
||||
return '127.0.0.1'
|
||||
|
||||
@default('authenticator_class')
|
||||
def _authenticator_class_default(self):
|
||||
return MockPAMAuthenticator
|
||||
|
||||
@default('spawner_class')
|
||||
def _spawner_class_default(self):
|
||||
return MockSpawner
|
||||
|
||||
@@ -124,7 +148,8 @@ class MockHub(JupyterHub):
|
||||
|
||||
def start(self, argv=None):
|
||||
self.db_file = NamedTemporaryFile()
|
||||
self.db_url = 'sqlite:///' + self.db_file.name
|
||||
self.pid_file = NamedTemporaryFile(delete=False).name
|
||||
self.db_url = self.db_file.name
|
||||
|
||||
evt = threading.Event()
|
||||
|
||||
@@ -161,13 +186,90 @@ class MockHub(JupyterHub):
|
||||
self.db_file.close()
|
||||
|
||||
def login_user(self, name):
|
||||
r = requests.post(self.proxy.public_server.url + 'hub/login',
|
||||
"""Login a user by name, returning her cookies."""
|
||||
base_url = public_url(self)
|
||||
r = requests.post(base_url + 'hub/login',
|
||||
data={
|
||||
'username': name,
|
||||
'password': name,
|
||||
},
|
||||
allow_redirects=False,
|
||||
)
|
||||
r.raise_for_status()
|
||||
assert r.cookies
|
||||
return r.cookies
|
||||
|
||||
|
||||
def public_host(app):
|
||||
"""Return the public *host* (no URL prefix) of the given JupyterHub instance."""
|
||||
if app.subdomain_host:
|
||||
return app.subdomain_host
|
||||
else:
|
||||
return app.proxy.public_server.host
|
||||
|
||||
|
||||
def public_url(app, user_or_service=None):
|
||||
"""Return the full, public base URL (including prefix) of the given JupyterHub instance."""
|
||||
if user_or_service:
|
||||
if app.subdomain_host:
|
||||
host = user_or_service.host
|
||||
else:
|
||||
host = public_host(app)
|
||||
return host + user_or_service.server.base_url
|
||||
else:
|
||||
return public_host(app) + app.proxy.public_server.base_url
|
||||
|
||||
|
||||
# single-user-server mocking:
|
||||
|
||||
class MockSingleUserServer(SingleUserNotebookApp):
|
||||
"""Mock-out problematic parts of single-user server when run in a thread
|
||||
|
||||
Currently:
|
||||
|
||||
- disable signal handler
|
||||
"""
|
||||
|
||||
def init_signal(self):
|
||||
pass
|
||||
|
||||
|
||||
class TestSingleUserSpawner(MockSpawner):
|
||||
"""Spawner that starts a MockSingleUserServer in a thread."""
|
||||
_thread = None
|
||||
@gen.coroutine
|
||||
def start(self):
|
||||
self.user.server.port = random_port()
|
||||
env = self.get_env()
|
||||
args = self.get_args()
|
||||
evt = threading.Event()
|
||||
print(args, env)
|
||||
def _run():
|
||||
io_loop = IOLoop()
|
||||
io_loop.make_current()
|
||||
io_loop.add_callback(lambda : evt.set())
|
||||
|
||||
with mock.patch.dict(os.environ, env):
|
||||
app = self._app = MockSingleUserServer()
|
||||
app.initialize(args)
|
||||
app.start()
|
||||
|
||||
self._thread = threading.Thread(target=_run)
|
||||
self._thread.start()
|
||||
ready = evt.wait(timeout=3)
|
||||
assert ready
|
||||
|
||||
@gen.coroutine
|
||||
def stop(self):
|
||||
self._app.stop()
|
||||
self._thread.join()
|
||||
|
||||
@gen.coroutine
|
||||
def poll(self):
|
||||
if self._thread is None:
|
||||
return 0
|
||||
if self._thread.is_alive():
|
||||
return None
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
67
jupyterhub/tests/mockservice.py
Normal file
67
jupyterhub/tests/mockservice.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Mock service for testing
|
||||
|
||||
basic HTTP Server that echos URLs back,
|
||||
and allow retrieval of sys.argv.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from tornado import web, httpserver, ioloop
|
||||
|
||||
from jupyterhub.services.auth import HubAuthenticated
|
||||
|
||||
class EchoHandler(web.RequestHandler):
|
||||
def get(self):
|
||||
self.write(self.request.path)
|
||||
|
||||
|
||||
class EnvHandler(web.RequestHandler):
|
||||
def get(self):
|
||||
self.set_header('Content-Type', 'application/json')
|
||||
self.write(json.dumps(dict(os.environ)))
|
||||
|
||||
|
||||
class APIHandler(web.RequestHandler):
|
||||
def get(self, path):
|
||||
api_token = os.environ['JUPYTERHUB_API_TOKEN']
|
||||
api_url = os.environ['JUPYTERHUB_API_URL']
|
||||
r = requests.get(api_url + path, headers={
|
||||
'Authorization': 'token %s' % api_token
|
||||
})
|
||||
r.raise_for_status()
|
||||
self.set_header('Content-Type', 'application/json')
|
||||
self.write(r.text)
|
||||
|
||||
class WhoAmIHandler(HubAuthenticated, web.RequestHandler):
|
||||
|
||||
@web.authenticated
|
||||
def get(self):
|
||||
self.write(self.get_current_user())
|
||||
|
||||
|
||||
def main():
|
||||
if os.environ['JUPYTERHUB_SERVICE_URL']:
|
||||
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
|
||||
app = web.Application([
|
||||
(r'.*/env', EnvHandler),
|
||||
(r'.*/api/(.*)', APIHandler),
|
||||
(r'.*/whoami/?', WhoAmIHandler),
|
||||
(r'.*', EchoHandler),
|
||||
])
|
||||
|
||||
server = httpserver.HTTPServer(app)
|
||||
server.listen(url.port, url.hostname)
|
||||
try:
|
||||
ioloop.IOLoop.instance().start()
|
||||
except KeyboardInterrupt:
|
||||
print('\nInterrupted')
|
||||
|
||||
if __name__ == '__main__':
|
||||
from tornado.options import parse_command_line
|
||||
parse_command_line()
|
||||
main()
|
BIN
jupyterhub/tests/old-jupyterhub.sqlite
Normal file
BIN
jupyterhub/tests/old-jupyterhub.sqlite
Normal file
Binary file not shown.
@@ -2,18 +2,22 @@
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from queue import Queue
|
||||
from urllib.parse import urlparse
|
||||
import sys
|
||||
from unittest import mock
|
||||
from urllib.parse import urlparse, quote
|
||||
|
||||
from pytest import mark, yield_fixture
|
||||
import requests
|
||||
|
||||
from tornado import gen
|
||||
|
||||
import jupyterhub
|
||||
from .. import orm
|
||||
from ..user import User
|
||||
from ..utils import url_path_join as ujoin
|
||||
from . import mocking
|
||||
from .mocking import public_host, public_url
|
||||
|
||||
|
||||
def check_db_locks(func):
|
||||
@@ -21,14 +25,13 @@ def check_db_locks(func):
|
||||
Decorator for test functions that verifies no locks are held on the
|
||||
application's database upon exit by creating and dropping a dummy table.
|
||||
|
||||
Relies on an instance of JupyterhubApp being the first argument to the
|
||||
Relies on an instance of JupyterHubApp being the first argument to the
|
||||
decorated function.
|
||||
"""
|
||||
|
||||
def new_func(*args, **kwargs):
|
||||
retval = func(*args, **kwargs)
|
||||
def new_func(app, *args, **kwargs):
|
||||
retval = func(app, *args, **kwargs)
|
||||
|
||||
app = args[0]
|
||||
temp_session = app.session_factory()
|
||||
temp_session.execute('CREATE TABLE dummy (foo INT)')
|
||||
temp_session.execute('DROP TABLE dummy')
|
||||
@@ -41,10 +44,15 @@ def check_db_locks(func):
|
||||
|
||||
def find_user(db, name):
|
||||
return db.query(orm.User).filter(orm.User.name==name).first()
|
||||
|
||||
|
||||
def add_user(db, app=None, **kwargs):
|
||||
orm_user = orm.User(**kwargs)
|
||||
db.add(orm_user)
|
||||
orm_user = find_user(db, name=kwargs.get('name'))
|
||||
if orm_user is None:
|
||||
orm_user = orm.User(**kwargs)
|
||||
db.add(orm_user)
|
||||
else:
|
||||
for attr, value in kwargs.items():
|
||||
setattr(orm_user, attr, value)
|
||||
db.commit()
|
||||
if app:
|
||||
user = app.users[orm_user.id] = User(orm_user, app.tornado_settings)
|
||||
@@ -81,17 +89,17 @@ def test_auth_api(app):
|
||||
db = app.db
|
||||
r = api_request(app, 'authorizations', 'gobbledygook')
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# make a new cookie token
|
||||
user = db.query(orm.User).first()
|
||||
api_token = user.new_api_token()
|
||||
|
||||
|
||||
# check success:
|
||||
r = api_request(app, 'authorizations/token', api_token)
|
||||
assert r.status_code == 200
|
||||
reply = r.json()
|
||||
assert reply['name'] == user.name
|
||||
|
||||
|
||||
# check fail
|
||||
r = api_request(app, 'authorizations/token', api_token,
|
||||
headers={'Authorization': 'no sir'},
|
||||
@@ -105,7 +113,7 @@ def test_auth_api(app):
|
||||
|
||||
|
||||
def test_referer_check(app, io_loop):
|
||||
url = app.hub.server.url
|
||||
url = ujoin(public_host(app), app.hub.server.base_url)
|
||||
host = urlparse(url).netloc
|
||||
user = find_user(app.db, 'admin')
|
||||
if user is None:
|
||||
@@ -115,7 +123,7 @@ def test_referer_check(app, io_loop):
|
||||
# stop the admin's server so we don't mess up future tests
|
||||
io_loop.run_sync(lambda : app.proxy.delete_user(app_user))
|
||||
io_loop.run_sync(app_user.stop)
|
||||
|
||||
|
||||
r = api_request(app, 'users',
|
||||
headers={
|
||||
'Authorization': '',
|
||||
@@ -147,24 +155,28 @@ def test_referer_check(app, io_loop):
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
# user API tests
|
||||
|
||||
@mark.user
|
||||
def test_get_users(app):
|
||||
db = app.db
|
||||
r = api_request(app, 'users')
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
users = sorted(r.json(), key=lambda d: d['name'])
|
||||
for u in users:
|
||||
u.pop('last_activity')
|
||||
assert users == [
|
||||
{
|
||||
'name': 'admin',
|
||||
'groups': [],
|
||||
'admin': True,
|
||||
'server': None,
|
||||
'pending': None,
|
||||
},
|
||||
{
|
||||
'name': 'user',
|
||||
'groups': [],
|
||||
'admin': False,
|
||||
'server': None,
|
||||
'pending': None,
|
||||
@@ -176,6 +188,8 @@ def test_get_users(app):
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
@mark.user
|
||||
def test_add_user(app):
|
||||
db = app.db
|
||||
name = 'newuser'
|
||||
@@ -187,6 +201,7 @@ def test_add_user(app):
|
||||
assert not user.admin
|
||||
|
||||
|
||||
@mark.user
|
||||
def test_get_user(app):
|
||||
name = 'user'
|
||||
r = api_request(app, 'users', name)
|
||||
@@ -195,12 +210,14 @@ def test_get_user(app):
|
||||
user.pop('last_activity')
|
||||
assert user == {
|
||||
'name': name,
|
||||
'groups': [],
|
||||
'admin': False,
|
||||
'server': None,
|
||||
'pending': None,
|
||||
}
|
||||
|
||||
|
||||
@mark.user
|
||||
def test_add_multi_user_bad(app):
|
||||
r = api_request(app, 'users', method='post')
|
||||
assert r.status_code == 400
|
||||
@@ -210,6 +227,7 @@ def test_add_multi_user_bad(app):
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
@mark.user
|
||||
def test_add_multi_user_invalid(app):
|
||||
app.authenticator.username_pattern = r'w.*'
|
||||
r = api_request(app, 'users', method='post',
|
||||
@@ -220,6 +238,7 @@ def test_add_multi_user_invalid(app):
|
||||
assert r.json()['message'] == 'Invalid usernames: andrew, tara'
|
||||
|
||||
|
||||
@mark.user
|
||||
def test_add_multi_user(app):
|
||||
db = app.db
|
||||
names = ['a', 'b']
|
||||
@@ -230,21 +249,21 @@ def test_add_multi_user(app):
|
||||
reply = r.json()
|
||||
r_names = [ user['name'] for user in reply ]
|
||||
assert names == r_names
|
||||
|
||||
|
||||
for name in names:
|
||||
user = find_user(db, name)
|
||||
assert user is not None
|
||||
assert user.name == name
|
||||
assert not user.admin
|
||||
|
||||
|
||||
# try to create the same users again
|
||||
r = api_request(app, 'users', method='post',
|
||||
data=json.dumps({'usernames': names}),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
names = ['a', 'b', 'ab']
|
||||
|
||||
|
||||
# try to create the same users again
|
||||
r = api_request(app, 'users', method='post',
|
||||
data=json.dumps({'usernames': names}),
|
||||
@@ -255,6 +274,7 @@ def test_add_multi_user(app):
|
||||
assert r_names == ['ab']
|
||||
|
||||
|
||||
@mark.user
|
||||
def test_add_multi_user_admin(app):
|
||||
db = app.db
|
||||
names = ['c', 'd']
|
||||
@@ -265,7 +285,7 @@ def test_add_multi_user_admin(app):
|
||||
reply = r.json()
|
||||
r_names = [ user['name'] for user in reply ]
|
||||
assert names == r_names
|
||||
|
||||
|
||||
for name in names:
|
||||
user = find_user(db, name)
|
||||
assert user is not None
|
||||
@@ -273,6 +293,7 @@ def test_add_multi_user_admin(app):
|
||||
assert user.admin
|
||||
|
||||
|
||||
@mark.user
|
||||
def test_add_user_bad(app):
|
||||
db = app.db
|
||||
name = 'dne_newuser'
|
||||
@@ -281,6 +302,8 @@ def test_add_user_bad(app):
|
||||
user = find_user(db, name)
|
||||
assert user is None
|
||||
|
||||
|
||||
@mark.user
|
||||
def test_add_admin(app):
|
||||
db = app.db
|
||||
name = 'newadmin'
|
||||
@@ -293,13 +316,16 @@ def test_add_admin(app):
|
||||
assert user.name == name
|
||||
assert user.admin
|
||||
|
||||
|
||||
@mark.user
|
||||
def test_delete_user(app):
|
||||
db = app.db
|
||||
mal = add_user(db, name='mal')
|
||||
r = api_request(app, 'users', 'mal', method='delete')
|
||||
assert r.status_code == 204
|
||||
|
||||
|
||||
|
||||
@mark.user
|
||||
def test_make_admin(app):
|
||||
db = app.db
|
||||
name = 'admin2'
|
||||
@@ -319,9 +345,10 @@ def test_make_admin(app):
|
||||
assert user.name == name
|
||||
assert user.admin
|
||||
|
||||
|
||||
def get_app_user(app, name):
|
||||
"""Get the User object from the main thread
|
||||
|
||||
|
||||
Needed for access to the Spawner.
|
||||
No ORM methods should be called on the result.
|
||||
"""
|
||||
@@ -333,6 +360,7 @@ def get_app_user(app, name):
|
||||
user_id = q.get(timeout=2)
|
||||
return app.users[user_id]
|
||||
|
||||
|
||||
def test_spawn(app, io_loop):
|
||||
db = app.db
|
||||
name = 'wash'
|
||||
@@ -341,6 +369,7 @@ def test_spawn(app, io_loop):
|
||||
's': ['value'],
|
||||
'i': 5,
|
||||
}
|
||||
before_servers = sorted(db.query(orm.Server), key=lambda s: s.url)
|
||||
r = api_request(app, 'users', name, 'server', method='post', data=json.dumps(options))
|
||||
assert r.status_code == 201
|
||||
assert 'pid' in user.state
|
||||
@@ -350,31 +379,41 @@ def test_spawn(app, io_loop):
|
||||
assert not app_user.spawn_pending
|
||||
status = io_loop.run_sync(app_user.spawner.poll)
|
||||
assert status is None
|
||||
|
||||
assert user.server.base_url == '/user/%s' % name
|
||||
r = requests.get(ujoin(app.proxy.public_server.url, user.server.base_url))
|
||||
|
||||
assert user.server.base_url == ujoin(app.base_url, 'user/%s' % name)
|
||||
url = public_url(app, user)
|
||||
print(url)
|
||||
r = requests.get(url)
|
||||
assert r.status_code == 200
|
||||
assert r.text == user.server.base_url
|
||||
|
||||
r = requests.get(ujoin(app.proxy.public_server.url, user.server.base_url, 'args'))
|
||||
r = requests.get(ujoin(url, 'args'))
|
||||
assert r.status_code == 200
|
||||
argv = r.json()
|
||||
for expected in ['--user=%s' % name, '--base-url=%s' % user.server.base_url]:
|
||||
for expected in ['--user="%s"' % name, '--base-url="%s"' % user.server.base_url]:
|
||||
assert expected in argv
|
||||
|
||||
if app.subdomain_host:
|
||||
assert '--hub-host="%s"' % app.subdomain_host in argv
|
||||
|
||||
r = api_request(app, 'users', name, 'server', method='delete')
|
||||
assert r.status_code == 204
|
||||
|
||||
|
||||
assert 'pid' not in user.state
|
||||
status = io_loop.run_sync(app_user.spawner.poll)
|
||||
assert status == 0
|
||||
|
||||
def test_slow_spawn(app, io_loop):
|
||||
# app.tornado_application.settings['spawner_class'] = mocking.SlowSpawner
|
||||
app.tornado_settings['spawner_class'] = mocking.SlowSpawner
|
||||
app.tornado_application.settings['slow_spawn_timeout'] = 0
|
||||
app.tornado_application.settings['slow_stop_timeout'] = 0
|
||||
# check that we cleaned up after ourselves
|
||||
assert user.server is None
|
||||
after_servers = sorted(db.query(orm.Server), key=lambda s: s.url)
|
||||
assert before_servers == after_servers
|
||||
tokens = list(db.query(orm.APIToken).filter(orm.APIToken.user_id==user.id))
|
||||
assert tokens == []
|
||||
|
||||
|
||||
def test_slow_spawn(app, io_loop, no_patience, request):
|
||||
patch = mock.patch.dict(app.tornado_settings, {'spawner_class': mocking.SlowSpawner})
|
||||
patch.start()
|
||||
request.addfinalizer(patch.stop)
|
||||
db = app.db
|
||||
name = 'zoe'
|
||||
user = add_user(db, app=app, name=name)
|
||||
@@ -385,12 +424,12 @@ def test_slow_spawn(app, io_loop):
|
||||
assert app_user.spawner is not None
|
||||
assert app_user.spawn_pending
|
||||
assert not app_user.stop_pending
|
||||
|
||||
|
||||
@gen.coroutine
|
||||
def wait_spawn():
|
||||
while app_user.spawn_pending:
|
||||
yield gen.sleep(0.1)
|
||||
|
||||
|
||||
io_loop.run_sync(wait_spawn)
|
||||
assert not app_user.spawn_pending
|
||||
status = io_loop.run_sync(app_user.spawner.poll)
|
||||
@@ -412,17 +451,18 @@ def test_slow_spawn(app, io_loop):
|
||||
assert r.status_code == 202
|
||||
assert app_user.spawner is not None
|
||||
assert app_user.stop_pending
|
||||
|
||||
|
||||
io_loop.run_sync(wait_stop)
|
||||
assert not app_user.stop_pending
|
||||
assert app_user.spawner is not None
|
||||
r = api_request(app, 'users', name, 'server', method='delete')
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_never_spawn(app, io_loop):
|
||||
app.tornado_settings['spawner_class'] = mocking.NeverSpawner
|
||||
app.tornado_application.settings['slow_spawn_timeout'] = 0
|
||||
|
||||
def test_never_spawn(app, io_loop, no_patience, request):
|
||||
patch = mock.patch.dict(app.tornado_settings, {'spawner_class': mocking.NeverSpawner})
|
||||
patch.start()
|
||||
request.addfinalizer(patch.stop)
|
||||
|
||||
db = app.db
|
||||
name = 'badger'
|
||||
@@ -431,12 +471,12 @@ def test_never_spawn(app, io_loop):
|
||||
app_user = get_app_user(app, name)
|
||||
assert app_user.spawner is not None
|
||||
assert app_user.spawn_pending
|
||||
|
||||
|
||||
@gen.coroutine
|
||||
def wait_pending():
|
||||
while app_user.spawn_pending:
|
||||
yield gen.sleep(0.1)
|
||||
|
||||
|
||||
io_loop.run_sync(wait_pending)
|
||||
assert not app_user.spawn_pending
|
||||
status = io_loop.run_sync(app_user.spawner.poll)
|
||||
@@ -450,6 +490,295 @@ def test_get_proxy(app, io_loop):
|
||||
assert list(reply.keys()) == ['/']
|
||||
|
||||
|
||||
def test_cookie(app):
|
||||
db = app.db
|
||||
name = 'patience'
|
||||
user = add_user(db, app=app, name=name)
|
||||
r = api_request(app, 'users', name, 'server', method='post')
|
||||
assert r.status_code == 201
|
||||
assert 'pid' in user.state
|
||||
app_user = get_app_user(app, name)
|
||||
|
||||
cookies = app.login_user(name)
|
||||
# cookie jar gives '"cookie-value"', we want 'cookie-value'
|
||||
cookie = cookies[user.server.cookie_name][1:-1]
|
||||
r = api_request(app, 'authorizations/cookie', user.server.cookie_name, "nothintoseehere")
|
||||
assert r.status_code == 404
|
||||
|
||||
r = api_request(app, 'authorizations/cookie', user.server.cookie_name, quote(cookie, safe=''))
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
assert reply['name'] == name
|
||||
|
||||
# deprecated cookie in body:
|
||||
r = api_request(app, 'authorizations/cookie', user.server.cookie_name, data=cookie)
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
assert reply['name'] == name
|
||||
|
||||
|
||||
def test_token(app):
|
||||
name = 'book'
|
||||
user = add_user(app.db, app=app, name=name)
|
||||
token = user.new_api_token()
|
||||
r = api_request(app, 'authorizations/token', token)
|
||||
r.raise_for_status()
|
||||
user_model = r.json()
|
||||
assert user_model['name'] == name
|
||||
r = api_request(app, 'authorizations/token', 'notauthorized')
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_get_token(app):
|
||||
name = 'user'
|
||||
user = add_user(app.db, app=app, name=name)
|
||||
r = api_request(app, 'authorizations/token', method='post', data=json.dumps({
|
||||
'username': name,
|
||||
'password': name,
|
||||
}))
|
||||
assert r.status_code == 200
|
||||
data = r.content.decode("utf-8")
|
||||
token = json.loads(data)
|
||||
assert not token['Authentication'] is None
|
||||
|
||||
|
||||
def test_bad_get_token(app):
|
||||
name = 'user'
|
||||
password = 'fake'
|
||||
user = add_user(app.db, app=app, name=name)
|
||||
r = api_request(app, 'authorizations/token', method='post', data=json.dumps({
|
||||
'username': name,
|
||||
'password': password,
|
||||
}))
|
||||
assert r.status_code == 403
|
||||
|
||||
# group API tests
|
||||
|
||||
@mark.group
|
||||
def test_groups_list(app):
|
||||
r = api_request(app, 'groups')
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
assert reply == []
|
||||
|
||||
# create a group
|
||||
group = orm.Group(name='alphaflight')
|
||||
app.db.add(group)
|
||||
app.db.commit()
|
||||
|
||||
r = api_request(app, 'groups')
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
assert reply == [{
|
||||
'name': 'alphaflight',
|
||||
'users': []
|
||||
}]
|
||||
|
||||
|
||||
@mark.group
|
||||
def test_group_get(app):
|
||||
group = orm.Group.find(app.db, name='alphaflight')
|
||||
user = add_user(app.db, app=app, name='sasquatch')
|
||||
group.users.append(user)
|
||||
app.db.commit()
|
||||
|
||||
r = api_request(app, 'groups/runaways')
|
||||
assert r.status_code == 404
|
||||
|
||||
r = api_request(app, 'groups/alphaflight')
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
assert reply == {
|
||||
'name': 'alphaflight',
|
||||
'users': ['sasquatch']
|
||||
}
|
||||
|
||||
|
||||
@mark.group
|
||||
def test_group_create_delete(app):
|
||||
db = app.db
|
||||
r = api_request(app, 'groups/runaways', method='delete')
|
||||
assert r.status_code == 404
|
||||
|
||||
r = api_request(app, 'groups/new', method='post', data=json.dumps({
|
||||
'users': ['doesntexist']
|
||||
}))
|
||||
assert r.status_code == 400
|
||||
assert orm.Group.find(db, name='new') is None
|
||||
|
||||
r = api_request(app, 'groups/omegaflight', method='post', data=json.dumps({
|
||||
'users': ['sasquatch']
|
||||
}))
|
||||
r.raise_for_status()
|
||||
|
||||
omegaflight = orm.Group.find(db, name='omegaflight')
|
||||
sasquatch = find_user(db, name='sasquatch')
|
||||
assert omegaflight in sasquatch.groups
|
||||
assert sasquatch in omegaflight.users
|
||||
|
||||
# create duplicate raises 400
|
||||
r = api_request(app, 'groups/omegaflight', method='post')
|
||||
assert r.status_code == 400
|
||||
|
||||
r = api_request(app, 'groups/omegaflight', method='delete')
|
||||
assert r.status_code == 204
|
||||
assert omegaflight not in sasquatch.groups
|
||||
assert orm.Group.find(db, name='omegaflight') is None
|
||||
|
||||
# delete nonexistant gives 404
|
||||
r = api_request(app, 'groups/omegaflight', method='delete')
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@mark.group
|
||||
def test_group_add_users(app):
|
||||
db = app.db
|
||||
# must specify users
|
||||
r = api_request(app, 'groups/alphaflight/users', method='post', data='{}')
|
||||
assert r.status_code == 400
|
||||
|
||||
names = ['aurora', 'guardian', 'northstar', 'sasquatch', 'shaman', 'snowbird']
|
||||
users = [ find_user(db, name=name) or add_user(db, app=app, name=name) for name in names ]
|
||||
r = api_request(app, 'groups/alphaflight/users', method='post', data=json.dumps({
|
||||
'users': names,
|
||||
}))
|
||||
r.raise_for_status()
|
||||
|
||||
for user in users:
|
||||
print(user.name)
|
||||
assert [ g.name for g in user.groups ] == ['alphaflight']
|
||||
|
||||
group = orm.Group.find(db, name='alphaflight')
|
||||
assert sorted([ u.name for u in group.users ]) == sorted(names)
|
||||
|
||||
|
||||
@mark.group
|
||||
def test_group_delete_users(app):
|
||||
db = app.db
|
||||
# must specify users
|
||||
r = api_request(app, 'groups/alphaflight/users', method='delete', data='{}')
|
||||
assert r.status_code == 400
|
||||
|
||||
names = ['aurora', 'guardian', 'northstar', 'sasquatch', 'shaman', 'snowbird']
|
||||
users = [ find_user(db, name=name) for name in names ]
|
||||
r = api_request(app, 'groups/alphaflight/users', method='delete', data=json.dumps({
|
||||
'users': names[:2],
|
||||
}))
|
||||
r.raise_for_status()
|
||||
|
||||
for user in users[:2]:
|
||||
assert user.groups == []
|
||||
for user in users[2:]:
|
||||
assert [ g.name for g in user.groups ] == ['alphaflight']
|
||||
|
||||
group = orm.Group.find(db, name='alphaflight')
|
||||
assert sorted([ u.name for u in group.users ]) == sorted(names[2:])
|
||||
|
||||
|
||||
# service API
|
||||
@mark.services
|
||||
def test_get_services(app, mockservice):
|
||||
db = app.db
|
||||
r = api_request(app, 'services')
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 200
|
||||
|
||||
services = r.json()
|
||||
assert services == {
|
||||
'mock-service': {
|
||||
'name': 'mock-service',
|
||||
'admin': True,
|
||||
'command': mockservice.command,
|
||||
'pid': mockservice.proc.pid,
|
||||
'prefix': mockservice.server.base_url,
|
||||
'url': mockservice.url,
|
||||
}
|
||||
}
|
||||
|
||||
r = api_request(app, 'services',
|
||||
headers=auth_header(db, 'user'),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
@mark.services
|
||||
def test_get_service(app, mockservice):
|
||||
db = app.db
|
||||
r = api_request(app, 'services/%s' % mockservice.name)
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 200
|
||||
|
||||
service = r.json()
|
||||
assert service == {
|
||||
'name': 'mock-service',
|
||||
'admin': True,
|
||||
'command': mockservice.command,
|
||||
'pid': mockservice.proc.pid,
|
||||
'prefix': mockservice.server.base_url,
|
||||
'url': mockservice.url,
|
||||
}
|
||||
|
||||
r = api_request(app, 'services/%s' % mockservice.name,
|
||||
headers={
|
||||
'Authorization': 'token %s' % mockservice.api_token
|
||||
}
|
||||
)
|
||||
r.raise_for_status()
|
||||
r = api_request(app, 'services/%s' % mockservice.name,
|
||||
headers=auth_header(db, 'user'),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_root_api(app):
|
||||
base_url = app.hub.server.url
|
||||
url = ujoin(base_url, 'api')
|
||||
r = requests.get(url)
|
||||
r.raise_for_status()
|
||||
expected = {
|
||||
'version': jupyterhub.__version__
|
||||
}
|
||||
assert r.json() == expected
|
||||
|
||||
|
||||
def test_info(app):
|
||||
r = api_request(app, 'info')
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
assert data['version'] == jupyterhub.__version__
|
||||
assert sorted(data) == [
|
||||
'authenticator',
|
||||
'python',
|
||||
'spawner',
|
||||
'sys_executable',
|
||||
'version',
|
||||
]
|
||||
assert data['python'] == sys.version
|
||||
assert data['sys_executable'] == sys.executable
|
||||
assert data['authenticator'] == {
|
||||
'class': 'jupyterhub.tests.mocking.MockPAMAuthenticator',
|
||||
'version': jupyterhub.__version__,
|
||||
}
|
||||
assert data['spawner'] == {
|
||||
'class': 'jupyterhub.tests.mocking.MockSpawner',
|
||||
'version': jupyterhub.__version__,
|
||||
}
|
||||
|
||||
|
||||
# general API tests
|
||||
|
||||
def test_options(app):
|
||||
r = api_request(app, 'users', method='options')
|
||||
r.raise_for_status()
|
||||
assert 'Access-Control-Allow-Headers' in r.headers
|
||||
|
||||
|
||||
def test_bad_json_body(app):
|
||||
r = api_request(app, 'users', method='post', data='notjson')
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# shutdown must be last
|
||||
def test_shutdown(app):
|
||||
r = api_request(app, 'shutdown', method='post', data=json.dumps({
|
||||
'servers': True,
|
||||
@@ -463,3 +792,4 @@ def test_shutdown(app):
|
||||
else:
|
||||
break
|
||||
assert not app.io_loop._running
|
||||
|
||||
|
@@ -1,10 +1,17 @@
|
||||
"""Test the JupyterHub entry point"""
|
||||
|
||||
import binascii
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from subprocess import check_output
|
||||
from subprocess import check_output, Popen, PIPE
|
||||
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from .mocking import MockHub
|
||||
from .. import orm
|
||||
|
||||
def test_help_all():
|
||||
out = check_output([sys.executable, '-m', 'jupyterhub', '--help-all']).decode('utf8', 'replace')
|
||||
@@ -23,10 +30,23 @@ def test_token_app():
|
||||
def test_generate_config():
|
||||
with NamedTemporaryFile(prefix='jupyterhub_config', suffix='.py') as tf:
|
||||
cfg_file = tf.name
|
||||
|
||||
out = check_output([sys.executable, '-m', 'jupyterhub',
|
||||
'--generate-config', '-f', cfg_file]
|
||||
).decode('utf8', 'replace')
|
||||
with open(cfg_file, 'w') as f:
|
||||
f.write("c.A = 5")
|
||||
p = Popen([sys.executable, '-m', 'jupyterhub',
|
||||
'--generate-config', '-f', cfg_file],
|
||||
stdout=PIPE, stdin=PIPE)
|
||||
out, _ = p.communicate(b'n')
|
||||
out = out.decode('utf8', 'replace')
|
||||
assert os.path.exists(cfg_file)
|
||||
with open(cfg_file) as f:
|
||||
cfg_text = f.read()
|
||||
assert cfg_text == 'c.A = 5'
|
||||
|
||||
p = Popen([sys.executable, '-m', 'jupyterhub',
|
||||
'--generate-config', '-f', cfg_file],
|
||||
stdout=PIPE, stdin=PIPE)
|
||||
out, _ = p.communicate(b'x\ny')
|
||||
out = out.decode('utf8', 'replace')
|
||||
assert os.path.exists(cfg_file)
|
||||
with open(cfg_file) as f:
|
||||
cfg_text = f.read()
|
||||
@@ -34,3 +54,105 @@ def test_generate_config():
|
||||
assert cfg_file in out
|
||||
assert 'Spawner.cmd' in cfg_text
|
||||
assert 'Authenticator.whitelist' in cfg_text
|
||||
|
||||
def test_init_tokens(io_loop):
|
||||
with TemporaryDirectory() as td:
|
||||
db_file = os.path.join(td, 'jupyterhub.sqlite')
|
||||
tokens = {
|
||||
'super-secret-token': 'alyx',
|
||||
'also-super-secret': 'gordon',
|
||||
'boagasdfasdf': 'chell',
|
||||
}
|
||||
app = MockHub(db_url=db_file, api_tokens=tokens)
|
||||
io_loop.run_sync(lambda : app.initialize([]))
|
||||
db = app.db
|
||||
for token, username in tokens.items():
|
||||
api_token = orm.APIToken.find(db, token)
|
||||
assert api_token is not None
|
||||
user = api_token.user
|
||||
assert user.name == username
|
||||
|
||||
# simulate second startup, reloading same tokens:
|
||||
app = MockHub(db_url=db_file, api_tokens=tokens)
|
||||
io_loop.run_sync(lambda : app.initialize([]))
|
||||
db = app.db
|
||||
for token, username in tokens.items():
|
||||
api_token = orm.APIToken.find(db, token)
|
||||
assert api_token is not None
|
||||
user = api_token.user
|
||||
assert user.name == username
|
||||
|
||||
# don't allow failed token insertion to create users:
|
||||
tokens['short'] = 'gman'
|
||||
app = MockHub(db_url=db_file, api_tokens=tokens)
|
||||
with pytest.raises(ValueError):
|
||||
io_loop.run_sync(lambda : app.initialize([]))
|
||||
assert orm.User.find(app.db, 'gman') is None
|
||||
|
||||
|
||||
def test_write_cookie_secret(tmpdir):
|
||||
secret_path = str(tmpdir.join('cookie_secret'))
|
||||
hub = MockHub(cookie_secret_file=secret_path)
|
||||
hub.init_secrets()
|
||||
assert os.path.exists(secret_path)
|
||||
assert os.stat(secret_path).st_mode & 0o600
|
||||
assert not os.stat(secret_path).st_mode & 0o177
|
||||
|
||||
|
||||
def test_cookie_secret_permissions(tmpdir):
|
||||
secret_file = tmpdir.join('cookie_secret')
|
||||
secret_path = str(secret_file)
|
||||
secret = os.urandom(1024)
|
||||
secret_file.write(binascii.b2a_base64(secret))
|
||||
hub = MockHub(cookie_secret_file=secret_path)
|
||||
|
||||
# raise with public secret file
|
||||
os.chmod(secret_path, 0o664)
|
||||
with pytest.raises(SystemExit):
|
||||
hub.init_secrets()
|
||||
|
||||
# ok with same file, proper permissions
|
||||
os.chmod(secret_path, 0o660)
|
||||
hub.init_secrets()
|
||||
assert hub.cookie_secret == secret
|
||||
|
||||
|
||||
def test_cookie_secret_content(tmpdir):
|
||||
secret_file = tmpdir.join('cookie_secret')
|
||||
secret_file.write('not base 64: uñiço∂e')
|
||||
secret_path = str(secret_file)
|
||||
os.chmod(secret_path, 0o660)
|
||||
hub = MockHub(cookie_secret_file=secret_path)
|
||||
with pytest.raises(SystemExit):
|
||||
hub.init_secrets()
|
||||
|
||||
|
||||
def test_cookie_secret_env(tmpdir):
|
||||
hub = MockHub(cookie_secret_file=str(tmpdir.join('cookie_secret')))
|
||||
|
||||
with patch.dict(os.environ, {'JPY_COOKIE_SECRET': 'not hex'}):
|
||||
with pytest.raises(ValueError):
|
||||
hub.init_secrets()
|
||||
|
||||
with patch.dict(os.environ, {'JPY_COOKIE_SECRET': 'abc123'}):
|
||||
hub.init_secrets()
|
||||
assert hub.cookie_secret == binascii.a2b_hex('abc123')
|
||||
assert not os.path.exists(hub.cookie_secret_file)
|
||||
|
||||
|
||||
def test_load_groups(io_loop):
|
||||
to_load = {
|
||||
'blue': ['cyclops', 'rogue', 'wolverine'],
|
||||
'gold': ['storm', 'jean-grey', 'colossus'],
|
||||
}
|
||||
hub = MockHub(load_groups=to_load)
|
||||
hub.init_db()
|
||||
io_loop.run_sync(hub.init_users)
|
||||
hub.init_groups()
|
||||
db = hub.db
|
||||
blue = orm.Group.find(db, name='blue')
|
||||
assert blue is not None
|
||||
assert sorted([ u.name for u in blue.users ]) == sorted(to_load['blue'])
|
||||
gold = orm.Group.find(db, name='gold')
|
||||
assert gold is not None
|
||||
assert sorted([ u.name for u in gold.users ]) == sorted(to_load['gold'])
|
||||
|
@@ -168,6 +168,23 @@ def test_normalize_names(io_loop):
|
||||
}))
|
||||
assert authorized == 'zoe'
|
||||
|
||||
authorized = io_loop.run_sync(lambda: a.get_authenticated_user(None, {
|
||||
'username': 'Glenn',
|
||||
'password': 'Glenn',
|
||||
}))
|
||||
assert authorized == 'glenn'
|
||||
|
||||
authorized = io_loop.run_sync(lambda: a.get_authenticated_user(None, {
|
||||
'username': 'hExi',
|
||||
'password': 'hExi',
|
||||
}))
|
||||
assert authorized == 'hexi'
|
||||
|
||||
authorized = io_loop.run_sync(lambda: a.get_authenticated_user(None, {
|
||||
'username': 'Test',
|
||||
'password': 'Test',
|
||||
}))
|
||||
assert authorized == 'test'
|
||||
|
||||
def test_username_map(io_loop):
|
||||
a = MockPAMAuthenticator(username_map={'wash': 'alpha'})
|
||||
@@ -189,6 +206,9 @@ def test_validate_names(io_loop):
|
||||
a = auth.PAMAuthenticator()
|
||||
assert a.validate_username('willow')
|
||||
assert a.validate_username('giles')
|
||||
assert a.validate_username('Test')
|
||||
assert a.validate_username('hExi')
|
||||
assert a.validate_username('Glenn#Smith!')
|
||||
a = auth.PAMAuthenticator(username_pattern='w.*')
|
||||
assert not a.validate_username('xander')
|
||||
assert a.validate_username('willow')
|
||||
|
48
jupyterhub/tests/test_db.py
Normal file
48
jupyterhub/tests/test_db.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from glob import glob
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from pytest import raises
|
||||
|
||||
from ..dbutil import upgrade
|
||||
from ..app import NewToken, UpgradeDB, JupyterHub
|
||||
|
||||
|
||||
here = os.path.dirname(__file__)
|
||||
old_db = os.path.join(here, 'old-jupyterhub.sqlite')
|
||||
|
||||
def generate_old_db(path):
|
||||
db_path = os.path.join(path, "jupyterhub.sqlite")
|
||||
print(old_db, db_path)
|
||||
shutil.copy(old_db, db_path)
|
||||
return 'sqlite:///%s' % db_path
|
||||
|
||||
def test_upgrade(tmpdir):
|
||||
print(tmpdir)
|
||||
db_url = generate_old_db(str(tmpdir))
|
||||
print(db_url)
|
||||
upgrade(db_url)
|
||||
|
||||
def test_upgrade_entrypoint(tmpdir, io_loop):
|
||||
generate_old_db(str(tmpdir))
|
||||
tmpdir.chdir()
|
||||
tokenapp = NewToken()
|
||||
tokenapp.initialize(['kaylee'])
|
||||
with raises(OperationalError):
|
||||
tokenapp.start()
|
||||
|
||||
sqlite_files = glob(os.path.join(str(tmpdir), 'jupyterhub.sqlite*'))
|
||||
assert len(sqlite_files) == 1
|
||||
|
||||
upgradeapp = UpgradeDB()
|
||||
io_loop.run_sync(lambda : upgradeapp.initialize([]))
|
||||
upgradeapp.start()
|
||||
|
||||
# check that backup was created:
|
||||
sqlite_files = glob(os.path.join(str(tmpdir), 'jupyterhub.sqlite*'))
|
||||
assert len(sqlite_files) == 2
|
||||
|
||||
# run tokenapp again, it should work
|
||||
tokenapp.start()
|
||||
|
@@ -20,7 +20,7 @@ def test_server(db):
|
||||
assert server.proto == 'http'
|
||||
assert isinstance(server.port, int)
|
||||
assert isinstance(server.cookie_name, str)
|
||||
assert server.host == 'http://localhost:%i' % server.port
|
||||
assert server.host == 'http://127.0.0.1:%i' % server.port
|
||||
assert server.url == server.host + '/'
|
||||
assert server.bind_url == 'http://*:%i/' % server.port
|
||||
server.ip = '127.0.0.1'
|
||||
@@ -90,9 +90,82 @@ def test_tokens(db):
|
||||
assert len(user.api_tokens) == 2
|
||||
found = orm.APIToken.find(db, token=token)
|
||||
assert found.match(token)
|
||||
assert found.user is user
|
||||
assert found.service is None
|
||||
found = orm.APIToken.find(db, 'something else')
|
||||
assert found is None
|
||||
|
||||
secret = 'super-secret-preload-token'
|
||||
token = user.new_api_token(secret)
|
||||
assert token == secret
|
||||
assert len(user.api_tokens) == 3
|
||||
|
||||
# raise ValueError on collision
|
||||
with pytest.raises(ValueError):
|
||||
user.new_api_token(token)
|
||||
assert len(user.api_tokens) == 3
|
||||
|
||||
|
||||
def test_service_tokens(db):
|
||||
service = orm.Service(name='secret')
|
||||
db.add(service)
|
||||
db.commit()
|
||||
token = service.new_api_token()
|
||||
assert any(t.match(token) for t in service.api_tokens)
|
||||
service.new_api_token()
|
||||
assert len(service.api_tokens) == 2
|
||||
found = orm.APIToken.find(db, token=token)
|
||||
assert found.match(token)
|
||||
assert found.user is None
|
||||
assert found.service is service
|
||||
service2 = orm.Service(name='secret')
|
||||
db.add(service)
|
||||
db.commit()
|
||||
assert service2.id != service.id
|
||||
|
||||
|
||||
def test_service_server(db):
|
||||
service = orm.Service(name='has_servers')
|
||||
db.add(service)
|
||||
db.commit()
|
||||
|
||||
assert service.server is None
|
||||
server = service.server = orm.Server()
|
||||
assert service
|
||||
assert server.id is None
|
||||
db.commit()
|
||||
assert isinstance(server.id, int)
|
||||
|
||||
|
||||
def test_token_find(db):
|
||||
service = db.query(orm.Service).first()
|
||||
user = db.query(orm.User).first()
|
||||
service_token = service.new_api_token()
|
||||
user_token = user.new_api_token()
|
||||
with pytest.raises(ValueError):
|
||||
orm.APIToken.find(db, 'irrelevant', kind='richard')
|
||||
# no kind, find anything
|
||||
found = orm.APIToken.find(db, token=user_token)
|
||||
assert found
|
||||
assert found.match(user_token)
|
||||
found = orm.APIToken.find(db, token=service_token)
|
||||
assert found
|
||||
assert found.match(service_token)
|
||||
|
||||
# kind=user, only find user tokens
|
||||
found = orm.APIToken.find(db, token=user_token, kind='user')
|
||||
assert found
|
||||
assert found.match(user_token)
|
||||
found = orm.APIToken.find(db, token=service_token, kind='user')
|
||||
assert found is None
|
||||
|
||||
# kind=service, only find service tokens
|
||||
found = orm.APIToken.find(db, token=service_token, kind='service')
|
||||
assert found
|
||||
assert found.match(service_token)
|
||||
found = orm.APIToken.find(db, token=user_token, kind='service')
|
||||
assert found is None
|
||||
|
||||
|
||||
def test_spawn_fails(db, io_loop):
|
||||
orm_user = orm.User(name='aeofel')
|
||||
@@ -114,3 +187,17 @@ def test_spawn_fails(db, io_loop):
|
||||
assert user.server is None
|
||||
assert not user.running
|
||||
|
||||
|
||||
def test_groups(db):
|
||||
user = orm.User.find(db, name='aeofel')
|
||||
db.add(user)
|
||||
|
||||
group = orm.Group(name='lives')
|
||||
db.add(group)
|
||||
db.commit()
|
||||
assert group.users == []
|
||||
assert user.groups == []
|
||||
group.users.append(user)
|
||||
db.commit()
|
||||
assert group.users == [user]
|
||||
assert user.groups == [group]
|
||||
|
@@ -1,6 +1,6 @@
|
||||
"""Tests for HTML pages"""
|
||||
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
import requests
|
||||
|
||||
@@ -8,12 +8,15 @@ from ..utils import url_path_join as ujoin
|
||||
from .. import orm
|
||||
|
||||
import mock
|
||||
from .mocking import FormSpawner
|
||||
from .mocking import FormSpawner, public_url, public_host
|
||||
from .test_api import api_request
|
||||
|
||||
|
||||
def get_page(path, app, **kw):
|
||||
base_url = ujoin(app.proxy.public_server.host, app.hub.server.base_url)
|
||||
def get_page(path, app, hub=True, **kw):
|
||||
if hub:
|
||||
prefix = app.hub.server.base_url
|
||||
else:
|
||||
prefix = app.base_url
|
||||
base_url = ujoin(public_host(app), prefix)
|
||||
print(base_url)
|
||||
return requests.get(ujoin(base_url, path), **kw)
|
||||
|
||||
@@ -22,15 +25,27 @@ def test_root_no_auth(app, io_loop):
|
||||
routes = io_loop.run_sync(app.proxy.get_routes)
|
||||
print(routes)
|
||||
print(app.hub.server)
|
||||
r = requests.get(app.proxy.public_server.host)
|
||||
url = ujoin(public_host(app), app.hub.server.base_url)
|
||||
print(url)
|
||||
r = requests.get(url)
|
||||
r.raise_for_status()
|
||||
assert r.url == ujoin(app.proxy.public_server.host, app.hub.server.base_url, 'login')
|
||||
assert r.url == ujoin(url, 'login')
|
||||
|
||||
def test_root_auth(app):
|
||||
cookies = app.login_user('river')
|
||||
r = requests.get(app.proxy.public_server.host, cookies=cookies)
|
||||
r = requests.get(public_url(app), cookies=cookies)
|
||||
r.raise_for_status()
|
||||
assert r.url == ujoin(app.proxy.public_server.host, '/user/river')
|
||||
assert r.url == public_url(app, app.users['river'])
|
||||
|
||||
def test_root_redirect(app):
|
||||
name = 'wash'
|
||||
cookies = app.login_user(name)
|
||||
next_url = ujoin(app.base_url, 'user/other/test.ipynb')
|
||||
url = '/?' + urlencode({'next': next_url})
|
||||
r = get_page(url, app, cookies=cookies)
|
||||
r.raise_for_status()
|
||||
path = urlparse(r.url).path
|
||||
assert path == ujoin(app.base_url, 'user/%s/test.ipynb' % name)
|
||||
|
||||
def test_home_no_auth(app):
|
||||
r = get_page('home', app, allow_redirects=False)
|
||||
@@ -62,6 +77,7 @@ def test_admin(app):
|
||||
r.raise_for_status()
|
||||
assert r.url.endswith('/admin')
|
||||
|
||||
|
||||
def test_spawn_redirect(app, io_loop):
|
||||
name = 'wash'
|
||||
cookies = app.login_user(name)
|
||||
@@ -78,7 +94,7 @@ def test_spawn_redirect(app, io_loop):
|
||||
r.raise_for_status()
|
||||
print(urlparse(r.url))
|
||||
path = urlparse(r.url).path
|
||||
assert path == '/user/%s' % name
|
||||
assert path == ujoin(app.base_url, 'user/%s' % name)
|
||||
|
||||
# should have started server
|
||||
status = io_loop.run_sync(u.spawner.poll)
|
||||
@@ -89,7 +105,7 @@ def test_spawn_redirect(app, io_loop):
|
||||
r.raise_for_status()
|
||||
print(urlparse(r.url))
|
||||
path = urlparse(r.url).path
|
||||
assert path == '/user/%s' % name
|
||||
assert path == ujoin(app.base_url, '/user/%s' % name)
|
||||
|
||||
def test_spawn_page(app):
|
||||
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
|
||||
@@ -100,7 +116,7 @@ def test_spawn_page(app):
|
||||
|
||||
def test_spawn_form(app, io_loop):
|
||||
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
|
||||
base_url = ujoin(app.proxy.public_server.host, app.hub.server.base_url)
|
||||
base_url = ujoin(public_host(app), app.hub.server.base_url)
|
||||
cookies = app.login_user('jones')
|
||||
orm_u = orm.User.find(app.db, 'jones')
|
||||
u = app.users[orm_u]
|
||||
@@ -121,7 +137,7 @@ def test_spawn_form(app, io_loop):
|
||||
|
||||
def test_spawn_form_with_file(app, io_loop):
|
||||
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
|
||||
base_url = ujoin(app.proxy.public_server.host, app.hub.server.base_url)
|
||||
base_url = ujoin(public_host(app), app.hub.server.base_url)
|
||||
cookies = app.login_user('jones')
|
||||
orm_u = orm.User.find(app.db, 'jones')
|
||||
u = app.users[orm_u]
|
||||
@@ -136,8 +152,6 @@ def test_spawn_form_with_file(app, io_loop):
|
||||
files={'hello': ('hello.txt', b'hello world\n')}
|
||||
)
|
||||
r.raise_for_status()
|
||||
print(u.spawner)
|
||||
print(u.spawner.user_options)
|
||||
assert u.spawner.user_options == {
|
||||
'energy': '511keV',
|
||||
'bounds': [-1, 1],
|
||||
@@ -147,3 +161,121 @@ def test_spawn_form_with_file(app, io_loop):
|
||||
'content_type': 'application/unknown'},
|
||||
}
|
||||
|
||||
|
||||
def test_user_redirect(app):
|
||||
name = 'wash'
|
||||
cookies = app.login_user(name)
|
||||
|
||||
r = get_page('/user-redirect/tree/top/', app)
|
||||
r.raise_for_status()
|
||||
print(urlparse(r.url))
|
||||
path = urlparse(r.url).path
|
||||
assert path == ujoin(app.base_url, '/hub/login')
|
||||
query = urlparse(r.url).query
|
||||
assert query == urlencode({
|
||||
'next': ujoin(app.hub.server.base_url, '/user-redirect/tree/top/')
|
||||
})
|
||||
|
||||
r = get_page('/user-redirect/notebooks/test.ipynb', app, cookies=cookies)
|
||||
r.raise_for_status()
|
||||
print(urlparse(r.url))
|
||||
path = urlparse(r.url).path
|
||||
assert path == ujoin(app.base_url, '/user/%s/notebooks/test.ipynb' % name)
|
||||
|
||||
|
||||
def test_user_redirect_deprecated(app):
|
||||
"""redirecting from /user/someonelse/ URLs (deprecated)"""
|
||||
name = 'wash'
|
||||
cookies = app.login_user(name)
|
||||
|
||||
r = get_page('/user/baduser', app, cookies=cookies, hub=False)
|
||||
r.raise_for_status()
|
||||
print(urlparse(r.url))
|
||||
path = urlparse(r.url).path
|
||||
assert path == ujoin(app.base_url, '/user/%s' % name)
|
||||
|
||||
r = get_page('/user/baduser/test.ipynb', app, cookies=cookies, hub=False)
|
||||
r.raise_for_status()
|
||||
print(urlparse(r.url))
|
||||
path = urlparse(r.url).path
|
||||
assert path == ujoin(app.base_url, '/user/%s/test.ipynb' % name)
|
||||
|
||||
r = get_page('/user/baduser/test.ipynb', app, hub=False)
|
||||
r.raise_for_status()
|
||||
print(urlparse(r.url))
|
||||
path = urlparse(r.url).path
|
||||
assert path == ujoin(app.base_url, '/hub/login')
|
||||
query = urlparse(r.url).query
|
||||
assert query == urlencode({
|
||||
'next': ujoin(app.base_url, '/hub/user/baduser/test.ipynb')
|
||||
})
|
||||
|
||||
|
||||
def test_login_fail(app):
|
||||
name = 'wash'
|
||||
base_url = public_url(app)
|
||||
r = requests.post(base_url + 'hub/login',
|
||||
data={
|
||||
'username': name,
|
||||
'password': 'wrong',
|
||||
},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert not r.cookies
|
||||
|
||||
|
||||
def test_login_redirect(app, io_loop):
|
||||
cookies = app.login_user('river')
|
||||
user = app.users['river']
|
||||
# no next_url, server running
|
||||
io_loop.run_sync(user.spawn)
|
||||
r = get_page('login', app, cookies=cookies, allow_redirects=False)
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 302
|
||||
assert '/user/river' in r.headers['Location']
|
||||
|
||||
# no next_url, server not running
|
||||
io_loop.run_sync(user.stop)
|
||||
r = get_page('login', app, cookies=cookies, allow_redirects=False)
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 302
|
||||
assert '/hub/' in r.headers['Location']
|
||||
|
||||
# next URL given, use it
|
||||
r = get_page('login?next=/hub/admin', app, cookies=cookies, allow_redirects=False)
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 302
|
||||
assert r.headers['Location'].endswith('/hub/admin')
|
||||
|
||||
|
||||
def test_logout(app):
|
||||
name = 'wash'
|
||||
cookies = app.login_user(name)
|
||||
r = requests.get(public_host(app) + app.tornado_settings['logout_url'], cookies=cookies)
|
||||
r.raise_for_status()
|
||||
login_url = public_host(app) + app.tornado_settings['login_url']
|
||||
assert r.url == login_url
|
||||
assert r.cookies == {}
|
||||
|
||||
|
||||
def test_login_no_whitelist_adds_user(app):
|
||||
auth = app.authenticator
|
||||
mock_add_user = mock.Mock()
|
||||
with mock.patch.object(auth, 'add_user', mock_add_user):
|
||||
cookies = app.login_user('jubal')
|
||||
|
||||
user = app.users['jubal']
|
||||
assert mock_add_user.mock_calls == [mock.call(user)]
|
||||
|
||||
|
||||
def test_static_files(app):
|
||||
base_url = ujoin(public_host(app), app.hub.server.base_url)
|
||||
r = requests.get(ujoin(base_url, 'logo'))
|
||||
r.raise_for_status()
|
||||
assert r.headers['content-type'] == 'image/png'
|
||||
r = requests.get(ujoin(base_url, 'static', 'images', 'jupyter.png'))
|
||||
r.raise_for_status()
|
||||
assert r.headers['content-type'] == 'image/png'
|
||||
r = requests.get(ujoin(base_url, 'static', 'css', 'style.min.css'))
|
||||
r.raise_for_status()
|
||||
assert r.headers['content-type'] == 'text/css'
|
||||
|
@@ -4,11 +4,12 @@ import json
|
||||
import os
|
||||
from queue import Queue
|
||||
from subprocess import Popen
|
||||
from urllib.parse import urlparse, unquote
|
||||
|
||||
from .. import orm
|
||||
from .mocking import MockHub
|
||||
from .test_api import api_request
|
||||
from ..utils import wait_for_http_server
|
||||
from ..utils import wait_for_http_server, url_path_join as ujoin
|
||||
|
||||
def test_external_proxy(request, io_loop):
|
||||
"""Test a proxy started before the Hub"""
|
||||
@@ -34,6 +35,8 @@ def test_external_proxy(request, io_loop):
|
||||
'--api-port', str(proxy_port),
|
||||
'--default-target', 'http://%s:%i' % (app.hub_ip, app.hub_port),
|
||||
]
|
||||
if app.subdomain_host:
|
||||
cmd.append('--host-routing')
|
||||
proxy = Popen(cmd, env=env)
|
||||
def _cleanup_proxy():
|
||||
if proxy.poll() is None:
|
||||
@@ -60,7 +63,11 @@ def test_external_proxy(request, io_loop):
|
||||
r.raise_for_status()
|
||||
|
||||
routes = io_loop.run_sync(app.proxy.get_routes)
|
||||
assert sorted(routes.keys()) == ['/', '/user/river']
|
||||
user_path = unquote(ujoin(app.base_url, 'user/river'))
|
||||
if app.subdomain_host:
|
||||
domain = urlparse(app.subdomain_host).hostname
|
||||
user_path = '/%s.%s' % (name, domain) + user_path
|
||||
assert sorted(routes.keys()) == ['/', user_path]
|
||||
|
||||
# teardown the proxy and start a new one in the same place
|
||||
proxy.terminate()
|
||||
@@ -76,7 +83,7 @@ def test_external_proxy(request, io_loop):
|
||||
|
||||
# check that the routes are correct
|
||||
routes = io_loop.run_sync(app.proxy.get_routes)
|
||||
assert sorted(routes.keys()) == ['/', '/user/river']
|
||||
assert sorted(routes.keys()) == ['/', user_path]
|
||||
|
||||
# teardown the proxy again, and start a new one with different auth and port
|
||||
proxy.terminate()
|
||||
@@ -90,13 +97,16 @@ def test_external_proxy(request, io_loop):
|
||||
'--api-port', str(proxy_port),
|
||||
'--default-target', 'http://%s:%i' % (app.hub_ip, app.hub_port),
|
||||
]
|
||||
|
||||
if app.subdomain_host:
|
||||
cmd.append('--host-routing')
|
||||
proxy = Popen(cmd, env=env)
|
||||
wait_for_proxy()
|
||||
|
||||
# tell the hub where the new proxy is
|
||||
r = api_request(app, 'proxy', method='patch', data=json.dumps({
|
||||
'port': proxy_port,
|
||||
'protocol': 'http',
|
||||
'ip': app.ip,
|
||||
'auth_token': new_auth_token,
|
||||
}))
|
||||
r.raise_for_status()
|
||||
@@ -113,7 +123,8 @@ def test_external_proxy(request, io_loop):
|
||||
|
||||
# check that the routes are correct
|
||||
routes = io_loop.run_sync(app.proxy.get_routes)
|
||||
assert sorted(routes.keys()) == ['/', '/user/river']
|
||||
assert sorted(routes.keys()) == ['/', user_path]
|
||||
|
||||
|
||||
def test_check_routes(app, io_loop):
|
||||
proxy = app.proxy
|
||||
@@ -123,13 +134,24 @@ def test_check_routes(app, io_loop):
|
||||
r.raise_for_status()
|
||||
zoe = orm.User.find(app.db, 'zoe')
|
||||
assert zoe is not None
|
||||
zoe = app.users[zoe]
|
||||
before = sorted(io_loop.run_sync(app.proxy.get_routes))
|
||||
assert '/user/zoe' in before
|
||||
io_loop.run_sync(app.proxy.check_routes)
|
||||
assert unquote(zoe.proxy_path) in before
|
||||
io_loop.run_sync(lambda : app.proxy.check_routes(app.users, app._service_map))
|
||||
io_loop.run_sync(lambda : proxy.delete_user(zoe))
|
||||
during = sorted(io_loop.run_sync(app.proxy.get_routes))
|
||||
assert '/user/zoe' not in during
|
||||
io_loop.run_sync(app.proxy.check_routes)
|
||||
assert unquote(zoe.proxy_path) not in during
|
||||
io_loop.run_sync(lambda : app.proxy.check_routes(app.users, app._service_map))
|
||||
after = sorted(io_loop.run_sync(app.proxy.get_routes))
|
||||
assert '/user/zoe' in after
|
||||
assert unquote(zoe.proxy_path) in after
|
||||
assert before == after
|
||||
|
||||
|
||||
def test_patch_proxy_bad_req(app):
|
||||
r = api_request(app, 'proxy', method='patch')
|
||||
assert r.status_code == 400
|
||||
r = api_request(app, 'proxy', method='patch', data='notjson')
|
||||
assert r.status_code == 400
|
||||
r = api_request(app, 'proxy', method='patch', data=json.dumps([]))
|
||||
assert r.status_code == 400
|
||||
|
104
jupyterhub/tests/test_services.py
Normal file
104
jupyterhub/tests/test_services.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Tests for services"""
|
||||
|
||||
from binascii import hexlify
|
||||
from contextlib import contextmanager
|
||||
import os
|
||||
from subprocess import Popen
|
||||
import sys
|
||||
from threading import Event
|
||||
import time
|
||||
|
||||
import requests
|
||||
from tornado import gen
|
||||
from tornado.ioloop import IOLoop
|
||||
|
||||
|
||||
from .mocking import public_url
|
||||
from ..utils import url_path_join, wait_for_http_server
|
||||
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
mockservice_py = os.path.join(here, 'mockservice.py')
|
||||
mockservice_cmd = [sys.executable, mockservice_py]
|
||||
|
||||
from ..utils import random_port
|
||||
|
||||
|
||||
@contextmanager
|
||||
def external_service(app, name='mockservice'):
|
||||
env = {
|
||||
'JUPYTERHUB_API_TOKEN': hexlify(os.urandom(5)),
|
||||
'JUPYTERHUB_SERVICE_NAME': name,
|
||||
'JUPYTERHUB_API_URL': url_path_join(app.hub.server.url, 'api/'),
|
||||
'JUPYTERHUB_SERVICE_URL': 'http://127.0.0.1:%i' % random_port(),
|
||||
}
|
||||
p = Popen(mockservice_cmd, env=env)
|
||||
IOLoop().run_sync(lambda : wait_for_http_server(env['JUPYTERHUB_SERVICE_URL']))
|
||||
try:
|
||||
yield env
|
||||
finally:
|
||||
p.terminate()
|
||||
|
||||
|
||||
def test_managed_service(app, mockservice):
|
||||
service = mockservice
|
||||
proc = service.proc
|
||||
first_pid = proc.pid
|
||||
assert proc.poll() is None
|
||||
# shut it down:
|
||||
proc.terminate()
|
||||
proc.wait(10)
|
||||
assert proc.poll() is not None
|
||||
# ensure Hub notices and brings it back up:
|
||||
for i in range(20):
|
||||
if service.proc is not proc:
|
||||
break
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
|
||||
assert service.proc.pid != first_pid
|
||||
assert service.proc.poll() is None
|
||||
|
||||
|
||||
def test_proxy_service(app, mockservice_url, io_loop):
|
||||
service = mockservice_url
|
||||
name = service.name
|
||||
routes = io_loop.run_sync(app.proxy.get_routes)
|
||||
url = public_url(app, service) + '/foo'
|
||||
r = requests.get(url, allow_redirects=False)
|
||||
path = '/services/{}/foo'.format(name)
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 200
|
||||
assert r.text.endswith(path)
|
||||
|
||||
|
||||
def test_external_service(app, io_loop):
|
||||
name = 'external'
|
||||
with external_service(app, name=name) as env:
|
||||
app.services = [{
|
||||
'name': name,
|
||||
'admin': True,
|
||||
'url': env['JUPYTERHUB_SERVICE_URL'],
|
||||
'api_token': env['JUPYTERHUB_API_TOKEN'],
|
||||
}]
|
||||
app.init_services()
|
||||
app.init_api_tokens()
|
||||
evt = Event()
|
||||
@gen.coroutine
|
||||
def add_services():
|
||||
yield app.proxy.add_all_services(app._service_map)
|
||||
evt.set()
|
||||
app.io_loop.add_callback(add_services)
|
||||
assert evt.wait(10)
|
||||
service = app._service_map[name]
|
||||
url = public_url(app, service) + '/api/users'
|
||||
path = '/services/{}/api/users'.format(name)
|
||||
r = requests.get(url, allow_redirects=False)
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 200
|
||||
resp = r.json()
|
||||
assert isinstance(resp, list)
|
||||
assert len(resp) >= 1
|
||||
assert isinstance(resp[0], dict)
|
||||
assert 'name' in resp[0]
|
||||
|
||||
|
209
jupyterhub/tests/test_services_auth.py
Normal file
209
jupyterhub/tests/test_services_auth.py
Normal file
@@ -0,0 +1,209 @@
|
||||
import json
|
||||
from queue import Queue
|
||||
import sys
|
||||
from threading import Thread
|
||||
import time
|
||||
from unittest import mock
|
||||
|
||||
from pytest import raises
|
||||
import requests
|
||||
import requests_mock
|
||||
|
||||
from tornado.ioloop import IOLoop
|
||||
from tornado.httpserver import HTTPServer
|
||||
from tornado.web import RequestHandler, Application, authenticated, HTTPError
|
||||
|
||||
from ..services.auth import _ExpiringDict, HubAuth, HubAuthenticated
|
||||
from ..utils import url_path_join
|
||||
from .mocking import public_url
|
||||
|
||||
# mock for sending monotonic counter way into the future
|
||||
monotonic_future = mock.patch('time.monotonic', lambda : sys.maxsize)
|
||||
|
||||
def test_expiring_dict():
|
||||
cache = _ExpiringDict(max_age=30)
|
||||
cache['key'] = 'cached value'
|
||||
assert 'key' in cache
|
||||
assert cache['key'] == 'cached value'
|
||||
|
||||
with raises(KeyError):
|
||||
cache['nokey']
|
||||
|
||||
with monotonic_future:
|
||||
assert 'key' not in cache
|
||||
|
||||
cache['key'] = 'cached value'
|
||||
assert 'key' in cache
|
||||
with monotonic_future:
|
||||
assert 'key' not in cache
|
||||
|
||||
cache['key'] = 'cached value'
|
||||
assert 'key' in cache
|
||||
with monotonic_future:
|
||||
with raises(KeyError):
|
||||
cache['key']
|
||||
|
||||
cache['key'] = 'cached value'
|
||||
assert 'key' in cache
|
||||
with monotonic_future:
|
||||
assert cache.get('key', 'default') == 'default'
|
||||
|
||||
cache.max_age = 0
|
||||
|
||||
cache['key'] = 'cached value'
|
||||
assert 'key' in cache
|
||||
with monotonic_future:
|
||||
assert cache.get('key', 'default') == 'cached value'
|
||||
|
||||
|
||||
def test_hub_auth():
|
||||
start = time.monotonic()
|
||||
auth = HubAuth(cookie_name='foo')
|
||||
mock_model = {
|
||||
'name': 'onyxia'
|
||||
}
|
||||
url = url_path_join(auth.api_url, "authorizations/cookie/foo/bar")
|
||||
with requests_mock.Mocker() as m:
|
||||
m.get(url, text=json.dumps(mock_model))
|
||||
user_model = auth.user_for_cookie('bar')
|
||||
assert user_model == mock_model
|
||||
# check cache
|
||||
user_model = auth.user_for_cookie('bar')
|
||||
assert user_model == mock_model
|
||||
|
||||
with requests_mock.Mocker() as m:
|
||||
m.get(url, status_code=404)
|
||||
user_model = auth.user_for_cookie('bar', use_cache=False)
|
||||
assert user_model is None
|
||||
|
||||
# invalidate cache with timer
|
||||
mock_model = {
|
||||
'name': 'willow'
|
||||
}
|
||||
with monotonic_future, requests_mock.Mocker() as m:
|
||||
m.get(url, text=json.dumps(mock_model))
|
||||
user_model = auth.user_for_cookie('bar')
|
||||
assert user_model == mock_model
|
||||
|
||||
with requests_mock.Mocker() as m:
|
||||
m.get(url, status_code=500)
|
||||
with raises(HTTPError) as exc_info:
|
||||
user_model = auth.user_for_cookie('bar', use_cache=False)
|
||||
assert exc_info.value.status_code == 502
|
||||
|
||||
with requests_mock.Mocker() as m:
|
||||
m.get(url, status_code=400)
|
||||
with raises(HTTPError) as exc_info:
|
||||
user_model = auth.user_for_cookie('bar', use_cache=False)
|
||||
assert exc_info.value.status_code == 500
|
||||
|
||||
|
||||
def test_hub_authenticated(request):
|
||||
auth = HubAuth(cookie_name='jubal')
|
||||
mock_model = {
|
||||
'name': 'jubalearly'
|
||||
}
|
||||
cookie_url = url_path_join(auth.api_url, "authorizations/cookie", auth.cookie_name)
|
||||
good_url = url_path_join(cookie_url, "early")
|
||||
bad_url = url_path_join(cookie_url, "late")
|
||||
|
||||
class TestHandler(HubAuthenticated, RequestHandler):
|
||||
hub_auth = auth
|
||||
@authenticated
|
||||
def get(self):
|
||||
self.finish(self.get_current_user())
|
||||
|
||||
# start hub-authenticated service in a thread:
|
||||
port = 50505
|
||||
q = Queue()
|
||||
def run():
|
||||
app = Application([
|
||||
('/*', TestHandler),
|
||||
], login_url=auth.login_url)
|
||||
|
||||
http_server = HTTPServer(app)
|
||||
http_server.listen(port)
|
||||
loop = IOLoop.current()
|
||||
loop.add_callback(lambda : q.put(loop))
|
||||
loop.start()
|
||||
|
||||
t = Thread(target=run)
|
||||
t.start()
|
||||
|
||||
def finish_thread():
|
||||
loop.stop()
|
||||
t.join()
|
||||
request.addfinalizer(finish_thread)
|
||||
|
||||
# wait for thread to start
|
||||
loop = q.get(timeout=10)
|
||||
|
||||
with requests_mock.Mocker(real_http=True) as m:
|
||||
# no cookie
|
||||
r = requests.get('http://127.0.0.1:%i' % port,
|
||||
allow_redirects=False,
|
||||
)
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 302
|
||||
assert auth.login_url in r.headers['Location']
|
||||
|
||||
# wrong cookie
|
||||
m.get(bad_url, status_code=404)
|
||||
r = requests.get('http://127.0.0.1:%i' % port,
|
||||
cookies={'jubal': 'late'},
|
||||
allow_redirects=False,
|
||||
)
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 302
|
||||
assert auth.login_url in r.headers['Location']
|
||||
|
||||
# upstream 403
|
||||
m.get(bad_url, status_code=403)
|
||||
r = requests.get('http://127.0.0.1:%i' % port,
|
||||
cookies={'jubal': 'late'},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 500
|
||||
|
||||
m.get(good_url, text=json.dumps(mock_model))
|
||||
|
||||
# no whitelist
|
||||
r = requests.get('http://127.0.0.1:%i' % port,
|
||||
cookies={'jubal': 'early'},
|
||||
allow_redirects=False,
|
||||
)
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 200
|
||||
|
||||
# pass whitelist
|
||||
TestHandler.hub_users = {'jubalearly'}
|
||||
r = requests.get('http://127.0.0.1:%i' % port,
|
||||
cookies={'jubal': 'early'},
|
||||
allow_redirects=False,
|
||||
)
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 200
|
||||
|
||||
# no pass whitelist
|
||||
TestHandler.hub_users = {'kaylee'}
|
||||
r = requests.get('http://127.0.0.1:%i' % port,
|
||||
cookies={'jubal': 'early'},
|
||||
allow_redirects=False,
|
||||
)
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 302
|
||||
assert auth.login_url in r.headers['Location']
|
||||
|
||||
|
||||
def test_service_cookie_auth(app, mockservice_url):
|
||||
cookies = app.login_user('badger')
|
||||
r = requests.get(public_url(app, mockservice_url) + '/whoami/', cookies=cookies)
|
||||
r.raise_for_status()
|
||||
print(r.text)
|
||||
reply = r.json()
|
||||
sub_reply = { key: reply.get(key, 'missing') for key in ['name', 'admin']}
|
||||
assert sub_reply == {
|
||||
'name': 'badger',
|
||||
'admin': False,
|
||||
}
|
||||
|
74
jupyterhub/tests/test_singleuser.py
Normal file
74
jupyterhub/tests/test_singleuser.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Tests for jupyterhub.singleuser"""
|
||||
|
||||
from subprocess import check_output
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
import jupyterhub
|
||||
from .mocking import TestSingleUserSpawner, public_url
|
||||
from ..utils import url_path_join
|
||||
|
||||
|
||||
def test_singleuser_auth(app, io_loop):
|
||||
# use TestSingleUserSpawner to launch a single-user app in a thread
|
||||
app.spawner_class = TestSingleUserSpawner
|
||||
app.tornado_settings['spawner_class'] = TestSingleUserSpawner
|
||||
|
||||
# login, start the server
|
||||
cookies = app.login_user('nandy')
|
||||
user = app.users['nandy']
|
||||
if not user.running:
|
||||
io_loop.run_sync(user.spawn)
|
||||
url = public_url(app, user)
|
||||
|
||||
# no cookies, redirects to login page
|
||||
r = requests.get(url)
|
||||
r.raise_for_status()
|
||||
assert '/hub/login' in r.url
|
||||
|
||||
# with cookies, login successful
|
||||
r = requests.get(url, cookies=cookies)
|
||||
r.raise_for_status()
|
||||
assert r.url.rstrip('/').endswith('/user/nandy/tree')
|
||||
assert r.status_code == 200
|
||||
|
||||
# logout
|
||||
r = requests.get(url_path_join(url, 'logout'), cookies=cookies)
|
||||
assert len(r.cookies) == 0
|
||||
|
||||
|
||||
def test_disable_user_config(app, io_loop):
|
||||
# use TestSingleUserSpawner to launch a single-user app in a thread
|
||||
app.spawner_class = TestSingleUserSpawner
|
||||
app.tornado_settings['spawner_class'] = TestSingleUserSpawner
|
||||
# login, start the server
|
||||
cookies = app.login_user('nandy')
|
||||
user = app.users['nandy']
|
||||
# stop spawner, if running:
|
||||
if user.running:
|
||||
print("stopping")
|
||||
io_loop.run_sync(user.stop)
|
||||
# start with new config:
|
||||
user.spawner.debug = True
|
||||
user.spawner.disable_user_config = True
|
||||
io_loop.run_sync(user.spawn)
|
||||
io_loop.run_sync(lambda : app.proxy.add_user(user))
|
||||
|
||||
url = public_url(app, user)
|
||||
|
||||
# with cookies, login successful
|
||||
r = requests.get(url, cookies=cookies)
|
||||
r.raise_for_status()
|
||||
assert r.url.rstrip('/').endswith('/user/nandy/tree')
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_help_output():
|
||||
out = check_output([sys.executable, '-m', 'jupyterhub.singleuser', '--help-all']).decode('utf8', 'replace')
|
||||
assert 'JupyterHub' in out
|
||||
|
||||
def test_version():
|
||||
out = check_output([sys.executable, '-m', 'jupyterhub.singleuser', '--version']).decode('utf8', 'replace')
|
||||
assert jupyterhub.__version__ in out
|
||||
|
@@ -4,9 +4,14 @@
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from unittest import mock
|
||||
|
||||
from tornado import gen
|
||||
|
||||
from .. import spawner as spawnermod
|
||||
from ..spawner import LocalProcessSpawner
|
||||
@@ -36,20 +41,29 @@ def new_spawner(db, **kwargs):
|
||||
kwargs.setdefault('cmd', [sys.executable, '-c', _echo_sleep])
|
||||
kwargs.setdefault('user', db.query(orm.User).first())
|
||||
kwargs.setdefault('hub', db.query(orm.Hub).first())
|
||||
kwargs.setdefault('notebook_dir', os.getcwd())
|
||||
kwargs.setdefault('default_url', '/user/{username}/lab')
|
||||
kwargs.setdefault('INTERRUPT_TIMEOUT', 1)
|
||||
kwargs.setdefault('TERM_TIMEOUT', 1)
|
||||
kwargs.setdefault('KILL_TIMEOUT', 1)
|
||||
kwargs.setdefault('poll_interval', 1)
|
||||
return LocalProcessSpawner(db=db, **kwargs)
|
||||
|
||||
|
||||
def test_spawner(db, io_loop):
|
||||
spawner = new_spawner(db)
|
||||
io_loop.run_sync(spawner.start)
|
||||
assert spawner.user.server.ip == 'localhost'
|
||||
ip, port = io_loop.run_sync(spawner.start)
|
||||
assert ip == '127.0.0.1'
|
||||
assert isinstance(port, int)
|
||||
assert port > 0
|
||||
spawner.user.server.ip = ip
|
||||
spawner.user.server.port = port
|
||||
db.commit()
|
||||
|
||||
|
||||
# wait for the process to get to the while True: loop
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
status = io_loop.run_sync(spawner.poll)
|
||||
assert status is None
|
||||
io_loop.run_sync(spawner.stop)
|
||||
@@ -58,8 +72,14 @@ def test_spawner(db, io_loop):
|
||||
|
||||
def test_single_user_spawner(db, io_loop):
|
||||
spawner = new_spawner(db, cmd=['jupyterhub-singleuser'])
|
||||
io_loop.run_sync(spawner.start)
|
||||
assert spawner.user.server.ip == 'localhost'
|
||||
spawner.api_token = 'secret'
|
||||
ip, port = io_loop.run_sync(spawner.start)
|
||||
assert ip == '127.0.0.1'
|
||||
assert isinstance(port, int)
|
||||
assert port > 0
|
||||
spawner.user.server.ip = ip
|
||||
spawner.user.server.port = port
|
||||
db.commit()
|
||||
# wait for http server to come up,
|
||||
# checking for early termination every 1s
|
||||
def wait():
|
||||
@@ -110,3 +130,65 @@ def test_stop_spawner_stop_now(db, io_loop):
|
||||
status = io_loop.run_sync(spawner.poll)
|
||||
assert status == -signal.SIGTERM
|
||||
|
||||
|
||||
def test_spawner_poll(db, io_loop):
|
||||
first_spawner = new_spawner(db)
|
||||
user = first_spawner.user
|
||||
io_loop.run_sync(first_spawner.start)
|
||||
proc = first_spawner.proc
|
||||
status = io_loop.run_sync(first_spawner.poll)
|
||||
assert status is None
|
||||
user.state = first_spawner.get_state()
|
||||
assert 'pid' in user.state
|
||||
|
||||
# create a new Spawner, loading from state of previous
|
||||
spawner = new_spawner(db, user=first_spawner.user)
|
||||
spawner.start_polling()
|
||||
|
||||
# wait for the process to get to the while True: loop
|
||||
io_loop.run_sync(lambda : gen.sleep(1))
|
||||
status = io_loop.run_sync(spawner.poll)
|
||||
assert status is None
|
||||
|
||||
# kill the process
|
||||
proc.terminate()
|
||||
for i in range(10):
|
||||
if proc.poll() is None:
|
||||
time.sleep(1)
|
||||
else:
|
||||
break
|
||||
assert proc.poll() is not None
|
||||
|
||||
io_loop.run_sync(lambda : gen.sleep(2))
|
||||
status = io_loop.run_sync(spawner.poll)
|
||||
assert status is not None
|
||||
|
||||
|
||||
def test_setcwd():
|
||||
cwd = os.getcwd()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
td = os.path.realpath(os.path.abspath(td))
|
||||
spawnermod._try_setcwd(td)
|
||||
assert os.path.samefile(os.getcwd(), td)
|
||||
os.chdir(cwd)
|
||||
chdir = os.chdir
|
||||
temp_root = os.path.realpath(os.path.abspath(tempfile.gettempdir()))
|
||||
def raiser(path):
|
||||
path = os.path.realpath(os.path.abspath(path))
|
||||
if not path.startswith(temp_root):
|
||||
raise OSError(path)
|
||||
chdir(path)
|
||||
with mock.patch('os.chdir', raiser):
|
||||
spawnermod._try_setcwd(cwd)
|
||||
assert os.getcwd().startswith(temp_root)
|
||||
os.chdir(cwd)
|
||||
|
||||
|
||||
def test_string_formatting(db):
|
||||
s = new_spawner(db, notebook_dir='user/%U/', default_url='/base/{username}')
|
||||
name = s.user.name
|
||||
assert s.notebook_dir == 'user/{username}/'
|
||||
assert s.default_url == '/base/{username}'
|
||||
assert s.format_string(s.notebook_dir) == 'user/%s/' % name
|
||||
assert s.format_string(s.default_url) == '/base/%s' % name
|
||||
|
||||
|
@@ -1,11 +1,12 @@
|
||||
from traitlets import HasTraits
|
||||
import pytest
|
||||
from traitlets import HasTraits, TraitError
|
||||
|
||||
from jupyterhub.traitlets import URLPrefix, Command, ByteSpecification
|
||||
|
||||
from jupyterhub.traitlets import URLPrefix, Command
|
||||
|
||||
def test_url_prefix():
|
||||
class C(HasTraits):
|
||||
url = URLPrefix()
|
||||
|
||||
c = C()
|
||||
c.url = '/a/b/c/'
|
||||
assert c.url == '/a/b/c/'
|
||||
@@ -14,14 +15,38 @@ def test_url_prefix():
|
||||
c.url = 'a/b/c/d'
|
||||
assert c.url == '/a/b/c/d/'
|
||||
|
||||
|
||||
def test_command():
|
||||
class C(HasTraits):
|
||||
cmd = Command('default command')
|
||||
cmd2 = Command(['default_cmd'])
|
||||
|
||||
c = C()
|
||||
assert c.cmd == ['default command']
|
||||
assert c.cmd2 == ['default_cmd']
|
||||
c.cmd = 'foo bar'
|
||||
assert c.cmd == ['foo bar']
|
||||
|
||||
|
||||
def test_memoryspec():
|
||||
class C(HasTraits):
|
||||
mem = ByteSpecification()
|
||||
|
||||
c = C()
|
||||
|
||||
c.mem = 1024
|
||||
assert c.mem == 1024
|
||||
|
||||
c.mem = '1024K'
|
||||
assert c.mem == 1024 * 1024
|
||||
|
||||
c.mem = '1024M'
|
||||
assert c.mem == 1024 * 1024 * 1024
|
||||
|
||||
c.mem = '1024G'
|
||||
assert c.mem == 1024 * 1024 * 1024 * 1024
|
||||
|
||||
c.mem = '1024T'
|
||||
assert c.mem == 1024 * 1024 * 1024 * 1024 * 1024
|
||||
|
||||
with pytest.raises(TraitError):
|
||||
c.mem = '1024Gi'
|
||||
|
@@ -1,8 +1,11 @@
|
||||
"""extra traitlets"""
|
||||
"""
|
||||
Traitlets that are used in JupyterHub
|
||||
"""
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
from traitlets import List, Unicode
|
||||
from traitlets import List, Unicode, Integer, TraitError
|
||||
|
||||
|
||||
class URLPrefix(Unicode):
|
||||
def validate(self, obj, value):
|
||||
@@ -21,9 +24,48 @@ class Command(List):
|
||||
kwargs.setdefault('minlen', 1)
|
||||
if isinstance(default_value, str):
|
||||
default_value = [default_value]
|
||||
super().__init__(Unicode, default_value, **kwargs)
|
||||
|
||||
super().__init__(Unicode(), default_value, **kwargs)
|
||||
|
||||
def validate(self, obj, value):
|
||||
if isinstance(value, str):
|
||||
value = [value]
|
||||
return super().validate(obj, value)
|
||||
|
||||
|
||||
class ByteSpecification(Integer):
|
||||
"""
|
||||
Allow easily specifying bytes in units of 1024 with suffixes
|
||||
|
||||
Suffixes allowed are:
|
||||
- K -> Kilobyte
|
||||
- M -> Megabyte
|
||||
- G -> Gigabyte
|
||||
- T -> Terabyte
|
||||
"""
|
||||
|
||||
UNIT_SUFFIXES = {
|
||||
'K': 1024,
|
||||
'M': 1024 * 1024,
|
||||
'G': 1024 * 1024 * 1024,
|
||||
'T': 1024 * 1024 * 1024 * 1024
|
||||
}
|
||||
|
||||
# Default to allowing None as a value
|
||||
allow_none = True
|
||||
|
||||
def validate(self, obj, value):
|
||||
"""
|
||||
Validate that the passed in value is a valid memory specification
|
||||
|
||||
It could either be a pure int, when it is taken as a byte value.
|
||||
If it has one of the suffixes, it is converted into the appropriate
|
||||
pure byte value.
|
||||
"""
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
num = value[:-1]
|
||||
suffix = value[-1]
|
||||
if not num.isdigit() and suffix not in ByteSpecification.UNIT_SUFFIXES:
|
||||
raise TraitError('{val} is not a valid memory specification. Must be an int or a string with suffix K, M, G, T'.format(val=value))
|
||||
else:
|
||||
return int(num) * ByteSpecification.UNIT_SUFFIXES[suffix]
|
||||
|
@@ -2,7 +2,7 @@
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
from tornado import gen
|
||||
from tornado.log import app_log
|
||||
@@ -12,7 +12,7 @@ from sqlalchemy import inspect
|
||||
from .utils import url_path_join
|
||||
|
||||
from . import orm
|
||||
from traitlets import HasTraits, Any, Dict
|
||||
from traitlets import HasTraits, Any, Dict, observe, default
|
||||
from .spawner import LocalProcessSpawner
|
||||
|
||||
|
||||
@@ -38,6 +38,12 @@ class UserDict(dict):
|
||||
def __getitem__(self, key):
|
||||
if isinstance(key, User):
|
||||
key = key.id
|
||||
elif isinstance(key, str):
|
||||
orm_user = self.db.query(orm.User).filter(orm.User.name==key).first()
|
||||
if orm_user is None:
|
||||
raise KeyError("No such user: %s" % key)
|
||||
else:
|
||||
key = orm_user
|
||||
if isinstance(key, orm.User):
|
||||
# users[orm_user] returns User(orm_user)
|
||||
orm_user = key
|
||||
@@ -69,28 +75,31 @@ class UserDict(dict):
|
||||
|
||||
class User(HasTraits):
|
||||
|
||||
@default('log')
|
||||
def _log_default(self):
|
||||
return app_log
|
||||
|
||||
settings = Dict()
|
||||
|
||||
db = Any(allow_none=True)
|
||||
@default('db')
|
||||
def _db_default(self):
|
||||
if self.orm_user:
|
||||
return inspect(self.orm_user).session
|
||||
|
||||
def _db_changed(self, name, old, new):
|
||||
@observe('db')
|
||||
def _db_changed(self, change):
|
||||
"""Changing db session reacquires ORM User object"""
|
||||
# db session changed, re-get orm User
|
||||
if self.orm_user:
|
||||
id = self.orm_user.id
|
||||
self.orm_user = new.query(orm.User).filter(orm.User.id==id).first()
|
||||
self.orm_user = change['new'].query(orm.User).filter(orm.User.id==id).first()
|
||||
self.spawner.db = self.db
|
||||
|
||||
orm_user = None
|
||||
spawner = None
|
||||
spawn_pending = False
|
||||
stop_pending = False
|
||||
waiting_for_response = False
|
||||
|
||||
@property
|
||||
def authenticator(self):
|
||||
@@ -139,6 +148,8 @@ class User(HasTraits):
|
||||
@property
|
||||
def running(self):
|
||||
"""property for whether a user has a running server"""
|
||||
if self.spawn_pending or self.stop_pending:
|
||||
return False # server is not running if spawn or stop is still pending
|
||||
if self.server is None:
|
||||
return False
|
||||
return True
|
||||
@@ -148,6 +159,41 @@ class User(HasTraits):
|
||||
"""My name, escaped for use in URLs, cookies, etc."""
|
||||
return quote(self.name, safe='@')
|
||||
|
||||
@property
|
||||
def proxy_path(self):
|
||||
if self.settings.get('subdomain_host'):
|
||||
return url_path_join('/' + self.domain, self.base_url)
|
||||
else:
|
||||
return self.base_url
|
||||
|
||||
@property
|
||||
def domain(self):
|
||||
"""Get the domain for my server."""
|
||||
# FIXME: escaped_name probably isn't escaped enough in general for a domain fragment
|
||||
return self.escaped_name + '.' + self.settings['domain']
|
||||
|
||||
@property
|
||||
def host(self):
|
||||
"""Get the *host* for my server (proto://domain[:port])"""
|
||||
# FIXME: escaped_name probably isn't escaped enough in general for a domain fragment
|
||||
parsed = urlparse(self.settings['subdomain_host'])
|
||||
h = '%s://%s.%s' % (parsed.scheme, self.escaped_name, parsed.netloc)
|
||||
return h
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
"""My URL
|
||||
|
||||
Full name.domain/path if using subdomains, otherwise just my /base/url
|
||||
"""
|
||||
if self.settings.get('subdomain_host'):
|
||||
return '{host}{path}'.format(
|
||||
host=self.host,
|
||||
path=self.base_url,
|
||||
)
|
||||
else:
|
||||
return self.base_url
|
||||
|
||||
@gen.coroutine
|
||||
def spawn(self, options=None):
|
||||
"""Start the user's spawner"""
|
||||
@@ -180,10 +226,23 @@ class User(HasTraits):
|
||||
f = spawner.start()
|
||||
# commit any changes in spawner.start (always commit db changes before yield)
|
||||
db.commit()
|
||||
yield gen.with_timeout(timedelta(seconds=spawner.start_timeout), f)
|
||||
ip_port = yield gen.with_timeout(timedelta(seconds=spawner.start_timeout), f)
|
||||
if ip_port:
|
||||
# get ip, port info from return value of start()
|
||||
self.server.ip, self.server.port = ip_port
|
||||
else:
|
||||
# prior to 0.7, spawners had to store this info in user.server themselves.
|
||||
# Handle < 0.7 behavior with a warning, assuming info was stored in db by the Spawner.
|
||||
self.log.warning("DEPRECATION: Spawner.start should return (ip, port) in JupyterHub >= 0.7")
|
||||
if spawner.api_token != api_token:
|
||||
# Spawner re-used an API token, discard the unused api_token
|
||||
orm_token = orm.APIToken.find(self.db, api_token)
|
||||
if orm_token is not None:
|
||||
self.db.delete(orm_token)
|
||||
self.db.commit()
|
||||
except Exception as e:
|
||||
if isinstance(e, gen.TimeoutError):
|
||||
self.log.warn("{user}'s server failed to start in {s} seconds, giving up".format(
|
||||
self.log.warning("{user}'s server failed to start in {s} seconds, giving up".format(
|
||||
user=self.name, s=spawner.start_timeout,
|
||||
))
|
||||
e.reason = 'timeout'
|
||||
@@ -206,11 +265,12 @@ class User(HasTraits):
|
||||
self.state = spawner.get_state()
|
||||
self.last_activity = datetime.utcnow()
|
||||
db.commit()
|
||||
self.waiting_for_response = True
|
||||
try:
|
||||
yield self.server.wait_up(http=True, timeout=spawner.http_timeout)
|
||||
except Exception as e:
|
||||
if isinstance(e, TimeoutError):
|
||||
self.log.warn(
|
||||
self.log.warning(
|
||||
"{user}'s server never showed up at {url} "
|
||||
"after {http_timeout} seconds. Giving up".format(
|
||||
user=self.name,
|
||||
@@ -232,7 +292,9 @@ class User(HasTraits):
|
||||
), exc_info=True)
|
||||
# raise original TimeoutError
|
||||
raise e
|
||||
self.spawn_pending = False
|
||||
finally:
|
||||
self.waiting_for_response = False
|
||||
self.spawn_pending = False
|
||||
return self
|
||||
|
||||
@gen.coroutine
|
||||
@@ -246,13 +308,24 @@ class User(HasTraits):
|
||||
self.spawner.stop_polling()
|
||||
self.stop_pending = True
|
||||
try:
|
||||
api_token = self.spawner.api_token
|
||||
status = yield spawner.poll()
|
||||
if status is None:
|
||||
yield self.spawner.stop()
|
||||
spawner.clear_state()
|
||||
self.state = spawner.get_state()
|
||||
self.last_activity = datetime.utcnow()
|
||||
# cleanup server entry, API token from defunct server
|
||||
if self.server:
|
||||
# cleanup server entry from db
|
||||
self.db.delete(self.server)
|
||||
self.server = None
|
||||
if not spawner.will_resume:
|
||||
# find and remove the API token if the spawner isn't
|
||||
# going to re-use it next time
|
||||
orm_token = orm.APIToken.find(self.db, api_token)
|
||||
if orm_token:
|
||||
self.db.delete(orm_token)
|
||||
self.db.commit()
|
||||
finally:
|
||||
self.stop_pending = False
|
||||
|
@@ -30,22 +30,32 @@ def random_port():
|
||||
ISO8601_ms = '%Y-%m-%dT%H:%M:%S.%fZ'
|
||||
ISO8601_s = '%Y-%m-%dT%H:%M:%SZ'
|
||||
|
||||
def can_connect(ip, port):
|
||||
"""Check if we can connect to an ip:port
|
||||
|
||||
return True if we can connect, False otherwise.
|
||||
"""
|
||||
try:
|
||||
socket.create_connection((ip, port))
|
||||
except socket.error as e:
|
||||
if e.errno not in {errno.ECONNREFUSED, errno.ETIMEDOUT}:
|
||||
app_log.error("Unexpected error connecting to %s:%i %s",
|
||||
ip, port, e
|
||||
)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
@gen.coroutine
|
||||
def wait_for_server(ip, port, timeout=10):
|
||||
"""wait for any server to show up at ip:port"""
|
||||
loop = ioloop.IOLoop.current()
|
||||
tic = loop.time()
|
||||
while loop.time() - tic < timeout:
|
||||
try:
|
||||
socket.create_connection((ip, port))
|
||||
except socket.error as e:
|
||||
if e.errno != errno.ECONNREFUSED:
|
||||
app_log.error("Unexpected error waiting for %s:%i %s",
|
||||
ip, port, e
|
||||
)
|
||||
yield gen.sleep(0.1)
|
||||
else:
|
||||
if can_connect(ip, port):
|
||||
return
|
||||
else:
|
||||
yield gen.sleep(0.1)
|
||||
raise TimeoutError("Server at {ip}:{port} didn't respond in {timeout} seconds".format(
|
||||
**locals()
|
||||
))
|
||||
@@ -68,14 +78,14 @@ def wait_for_http_server(url, timeout=10):
|
||||
if e.code != 599:
|
||||
# we expect 599 for no connection,
|
||||
# but 502 or other proxy error is conceivable
|
||||
app_log.warn("Server at %s responded with error: %s", url, e.code)
|
||||
app_log.warning("Server at %s responded with error: %s", url, e.code)
|
||||
yield gen.sleep(0.1)
|
||||
else:
|
||||
app_log.debug("Server at %s responded with %s", url, e.code)
|
||||
return
|
||||
except (OSError, socket.error) as e:
|
||||
if e.errno not in {errno.ECONNABORTED, errno.ECONNREFUSED, errno.ECONNRESET}:
|
||||
app_log.warn("Failed to connect to %s (%s)", url, e)
|
||||
app_log.warning("Failed to connect to %s (%s)", url, e)
|
||||
yield gen.sleep(0.1)
|
||||
else:
|
||||
return
|
||||
@@ -195,35 +205,3 @@ def url_path_join(*pieces):
|
||||
|
||||
return result
|
||||
|
||||
def localhost():
|
||||
"""Return localhost or 127.0.0.1"""
|
||||
if hasattr(localhost, '_localhost'):
|
||||
return localhost._localhost
|
||||
binder = connector = None
|
||||
try:
|
||||
binder = socket.socket()
|
||||
binder.bind(('localhost', 0))
|
||||
binder.listen(1)
|
||||
port = binder.getsockname()[1]
|
||||
def accept():
|
||||
try:
|
||||
conn, addr = binder.accept()
|
||||
except ConnectionAbortedError:
|
||||
pass
|
||||
else:
|
||||
conn.close()
|
||||
t = Thread(target=accept)
|
||||
t.start()
|
||||
connector = socket.create_connection(('localhost', port), timeout=10)
|
||||
t.join(timeout=10)
|
||||
except (socket.error, socket.gaierror) as e:
|
||||
warnings.warn("localhost doesn't appear to work, using 127.0.0.1\n%s" % e, RuntimeWarning)
|
||||
localhost._localhost = '127.0.0.1'
|
||||
else:
|
||||
localhost._localhost = 'localhost'
|
||||
finally:
|
||||
if binder:
|
||||
binder.close()
|
||||
if connector:
|
||||
connector.close()
|
||||
return localhost._localhost
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user