mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-13 21:13:01 +00:00
Compare commits
685 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa132cade7 | ||
|
|
dd35ffbe86 | ||
|
|
8edcf8be81 | ||
|
|
29b02b7bcb | ||
|
|
0383bc27b2 | ||
|
|
65d5102b49 | ||
|
|
8a226e6f46 | ||
|
|
0bd34e0a10 | ||
|
|
186107d959 | ||
|
|
91b07b7ea4 | ||
|
|
f5b30fd2b4 | ||
|
|
0234396c2c | ||
|
|
a43d677ae4 | ||
|
|
dcfe71e7f0 | ||
|
|
5d41376c2e | ||
|
|
dd083359ec | ||
|
|
e6d54960ba | ||
|
|
a9295bc5c2 | ||
|
|
2015c701fa | ||
|
|
3e9c18f50a | ||
|
|
7cac874afc | ||
|
|
a7b6bd8d32 | ||
|
|
1649a98656 | ||
|
|
ecbe51f60f | ||
|
|
fed14abed3 | ||
|
|
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 | ||
|
|
7837a9cf68 | ||
|
|
65a019e05b | ||
|
|
f2014c5687 | ||
|
|
109c315336 | ||
|
|
941fc7e627 | ||
|
|
f626d2f6e5 | ||
|
|
80215f6b3c | ||
|
|
84916062f0 | ||
|
|
641154bf06 | ||
|
|
14b0dbde0e | ||
|
|
cd85766441 | ||
|
|
6c072bdb3d | ||
|
|
35f080458e | ||
|
|
feac4f6bc4 | ||
|
|
1bbabbb989 |
@@ -1,4 +1,4 @@
|
|||||||
[run]
|
[run]
|
||||||
omit =
|
omit =
|
||||||
jupyterhub/tests/*
|
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
|
node_modules
|
||||||
*.py[co]
|
*.py[co]
|
||||||
*~
|
*~
|
||||||
|
.cache
|
||||||
.DS_Store
|
.DS_Store
|
||||||
build
|
build
|
||||||
dist
|
dist
|
||||||
docs/_build
|
docs/_build
|
||||||
|
docs/source/_static/rest-api
|
||||||
.ipynb_checkpoints
|
.ipynb_checkpoints
|
||||||
# ignore config file at the top-level of the repo
|
# ignore config file at the top-level of the repo
|
||||||
# but not sub-dirs
|
# but not sub-dirs
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
language: python
|
language: python
|
||||||
sudo: false
|
sudo: false
|
||||||
python:
|
python:
|
||||||
|
- 3.6-dev
|
||||||
- 3.5
|
- 3.5
|
||||||
- 3.4
|
- 3.4
|
||||||
- 3.3
|
- 3.3
|
||||||
@@ -10,8 +11,12 @@ before_install:
|
|||||||
- npm install -g configurable-http-proxy
|
- npm install -g configurable-http-proxy
|
||||||
- git clone --quiet --depth 1 https://github.com/minrk/travis-wheels travis-wheels
|
- git clone --quiet --depth 1 https://github.com/minrk/travis-wheels travis-wheels
|
||||||
install:
|
install:
|
||||||
- pip install -f travis-wheels/wheelhouse -r dev-requirements.txt .
|
- pip install --pre -f travis-wheels/wheelhouse -r dev-requirements.txt .
|
||||||
script:
|
script:
|
||||||
- py.test --cov jupyterhub jupyterhub/tests -v
|
- travis_retry py.test --cov jupyterhub jupyterhub/tests -v
|
||||||
after_success:
|
after_success:
|
||||||
- codecov
|
- 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
|
FROM debian:jessie
|
||||||
|
|
||||||
MAINTAINER Jupyter Project <jupyter@googlegroups.com>
|
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
|
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 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 && \
|
/usr/sbin/update-locale LANG=C.UTF-8 && \
|
||||||
locale-gen C.UTF-8 && \
|
locale-gen C.UTF-8 && \
|
||||||
apt-get remove -y locales && \
|
apt-get remove -y locales && \
|
||||||
@@ -21,30 +38,25 @@ RUN apt-get -y update && \
|
|||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
ENV LANG C.UTF-8
|
ENV LANG C.UTF-8
|
||||||
|
|
||||||
# install Python with conda
|
# install Python + NodeJS with conda
|
||||||
RUN wget -q https://repo.continuum.io/miniconda/Miniconda3-3.9.1-Linux-x86_64.sh -O /tmp/miniconda.sh && \
|
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 && \
|
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 && \
|
/opt/conda/bin/pip install --upgrade pip && \
|
||||||
rm /tmp/miniconda.sh
|
rm /tmp/miniconda.sh
|
||||||
ENV PATH=/opt/conda/bin:$PATH
|
ENV PATH=/opt/conda/bin:$PATH
|
||||||
|
|
||||||
# install js dependencies
|
ADD . /src/jupyterhub
|
||||||
RUN npm install -g configurable-http-proxy && rm -rf ~/.npm
|
WORKDIR /src/jupyterhub
|
||||||
|
|
||||||
WORKDIR /srv/
|
|
||||||
ADD . /srv/jupyterhub
|
|
||||||
WORKDIR /srv/jupyterhub/
|
|
||||||
|
|
||||||
RUN python setup.py js && pip install . && \
|
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/
|
WORKDIR /srv/jupyterhub/
|
||||||
|
|
||||||
# Derivative containers should add jupyterhub config,
|
|
||||||
# which will be used when starting the application.
|
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
ONBUILD ADD jupyterhub_config.py /srv/jupyterhub/jupyterhub_config.py
|
LABEL org.jupyter.service="jupyterhub"
|
||||||
CMD ["jupyterhub", "-f", "/srv/jupyterhub/jupyterhub_config.py"]
|
|
||||||
|
CMD ["jupyterhub"]
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ include setupegg.py
|
|||||||
include bower.json
|
include bower.json
|
||||||
include package.json
|
include package.json
|
||||||
include *requirements.txt
|
include *requirements.txt
|
||||||
|
include Dockerfile
|
||||||
|
|
||||||
|
graft onbuild
|
||||||
graft jupyterhub
|
graft jupyterhub
|
||||||
graft scripts
|
graft scripts
|
||||||
graft share
|
graft share
|
||||||
|
|
||||||
# Documentation
|
# Documentation
|
||||||
graft docs
|
graft docs
|
||||||
|
prune docs/node_modules
|
||||||
|
|
||||||
# prune some large unused files from components
|
# prune some large unused files from components
|
||||||
prune share/jupyter/hub/static/components/bootstrap/css
|
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/jupyterhub/jupyterhub)
|
||||||
[](https://travis-ci.org/jupyter/jupyterhub)
|
[](https://circleci.com/gh/jupyterhub/jupyterhub)
|
||||||
[](https://circleci.com/gh/jupyter/jupyterhub)
|
[](https://codecov.io/github/jupyterhub/jupyterhub?branch=master)
|
||||||
|
"
|
||||||
[](http://jupyterhub.readthedocs.org/en/latest/?badge=latest)
|
[](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)
|
by [Project Jupyter](https://jupyter.org)
|
||||||
- configurable http proxy (node-http-proxy)
|
|
||||||
- multiple single-user IPython notebook servers (Python/IPython/tornado)
|
|
||||||
|
|
||||||
Basic principles:
|
----
|
||||||
|
|
||||||
- Hub spawns proxy
|
## Technical overview
|
||||||
- Proxy forwards ~all requests to hub by default
|
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 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
|
- [Python](https://www.python.org/downloads/) 3.3 or greater
|
||||||
package manager. For example, install on Linux (Debian/Ubuntu) using:
|
|
||||||
|
|
||||||
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
|
- [nodejs/npm](https://www.npmjs.com/)
|
||||||
required for npm to work on Debian/Ubuntu.)
|
|
||||||
|
|
||||||
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:
|
- TLS certificate and key for HTTPS communication
|
||||||
- `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):
|
|
||||||
|
|
||||||
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
|
## 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
|
pip3 install --upgrade notebook
|
||||||
Jupyter ~~IPython~~ 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:
|
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:
|
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)
|
```bash
|
||||||
python3 setup.py css # recompile CSS from LESS sources
|
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.
|
## Getting help
|
||||||
|
We encourage you to ask questions on the [mailing list](https://groups.google.com/forum/#!forum/jupyter),
|
||||||
To allow multiple users to sign into the server, you will need to
|
and you may participate in development discussions or get live help on [Gitter](https://gitter.im/jupyterhub/jupyterhub).
|
||||||
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)
|
|
||||||
|
|
||||||
## Resources
|
## 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)
|
- [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:
|
test:
|
||||||
override:
|
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
|
-r requirements.txt
|
||||||
|
mock
|
||||||
codecov
|
codecov
|
||||||
pytest-cov
|
pytest-cov
|
||||||
pytest>=2.8
|
pytest>=2.8
|
||||||
notebook
|
notebook
|
||||||
|
requests-mock
|
||||||
|
|||||||
@@ -47,11 +47,20 @@ help:
|
|||||||
@echo " linkcheck to check all external links for integrity"
|
@echo " linkcheck to check all external links for integrity"
|
||||||
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
|
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
|
||||||
@echo " coverage to run coverage check of the documentation (if enabled)"
|
@echo " coverage to run coverage check of the documentation (if enabled)"
|
||||||
|
@echo " spelling to run spell check on documentation"
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf $(BUILDDIR)/*
|
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
|
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
|
||||||
@echo
|
@echo
|
||||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
|
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
|
||||||
@@ -171,6 +180,11 @@ linkcheck:
|
|||||||
@echo "Link check complete; look for any errors in the above output " \
|
@echo "Link check complete; look for any errors in the above output " \
|
||||||
"or in $(BUILDDIR)/linkcheck/output.txt."
|
"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:
|
doctest:
|
||||||
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
|
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
|
||||||
@echo "Testing of doctests in the sources finished, look at the " \
|
@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
|
-r ../requirements.txt
|
||||||
sphinx
|
sphinx>=1.3.6
|
||||||
recommonmark==0.4.0
|
recommonmark==0.4.0
|
||||||
@@ -3,9 +3,11 @@ swagger: '2.0'
|
|||||||
info:
|
info:
|
||||||
title: JupyterHub
|
title: JupyterHub
|
||||||
description: The REST API for JupyterHub
|
description: The REST API for JupyterHub
|
||||||
version: 0.4.0
|
version: 0.7.0
|
||||||
|
license:
|
||||||
|
name: BSD-3-Clause
|
||||||
schemes:
|
schemes:
|
||||||
- http
|
- [http, https]
|
||||||
securityDefinitions:
|
securityDefinitions:
|
||||||
token:
|
token:
|
||||||
type: apiKey
|
type: apiKey
|
||||||
@@ -13,18 +15,73 @@ securityDefinitions:
|
|||||||
in: header
|
in: header
|
||||||
security:
|
security:
|
||||||
- token: []
|
- token: []
|
||||||
basePath: /hub/api/
|
basePath: /hub/api
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
paths:
|
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:
|
/users:
|
||||||
get:
|
get:
|
||||||
summary: List users
|
summary: List users
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: The user list
|
description: The Hub's user list
|
||||||
schema:
|
schema:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -40,7 +97,7 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
usernames:
|
usernames:
|
||||||
type: array
|
type: array
|
||||||
description: list of usernames to create
|
description: list of usernames to create on the Hub
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
admin:
|
admin:
|
||||||
@@ -81,17 +138,6 @@ paths:
|
|||||||
description: The user has been created
|
description: The user has been created
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/User'
|
$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:
|
patch:
|
||||||
summary: Modify a user
|
summary: Modify a user
|
||||||
description: Change a user's name or admin status
|
description: Change a user's name or admin status
|
||||||
@@ -104,24 +150,35 @@ paths:
|
|||||||
- name: data
|
- name: data
|
||||||
in: body
|
in: body
|
||||||
required: true
|
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:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
description: the new name (optional)
|
description: the new name (optional, if another key is updated i.e. admin)
|
||||||
admin:
|
admin:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: update admin (optional)
|
description: update admin (optional, if another key is updated i.e. name)
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: The updated user info
|
description: The updated user info
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/User'
|
$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:
|
/users/{name}/server:
|
||||||
post:
|
post:
|
||||||
summary: Start a user's server
|
summary: Start a user's single-user notebook server
|
||||||
parameters:
|
parameters:
|
||||||
- name: name
|
- name: name
|
||||||
description: username
|
description: username
|
||||||
@@ -130,9 +187,9 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
description: The server has started
|
description: The user's notebook server has started
|
||||||
'202':
|
'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:
|
delete:
|
||||||
summary: Stop a user's server
|
summary: Stop a user's server
|
||||||
parameters:
|
parameters:
|
||||||
@@ -143,12 +200,12 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
responses:
|
responses:
|
||||||
'204':
|
'204':
|
||||||
description: The server has stopped
|
description: The user's notebook server has stopped
|
||||||
'202':
|
'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:
|
/users/{name}/admin-access:
|
||||||
post:
|
post:
|
||||||
summary: Grant an admin access to this user's server
|
summary: Grant admin access to this user's notebook server
|
||||||
parameters:
|
parameters:
|
||||||
- name: name
|
- name: name
|
||||||
description: username
|
description: username
|
||||||
@@ -157,25 +214,146 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'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:
|
/proxy:
|
||||||
get:
|
get:
|
||||||
summary: Get the proxy's routing table
|
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:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Routing table
|
description: Routing table
|
||||||
schema:
|
schema:
|
||||||
type: object
|
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:
|
post:
|
||||||
summary: Force the Hub to sync with the proxy
|
summary: Force the Hub to sync with the proxy
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Success
|
description: Success
|
||||||
patch:
|
patch:
|
||||||
summary: Tell the Hub about a new proxy
|
summary: Notify 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.
|
description: Notifies the Hub of a new proxy to use.
|
||||||
parameters:
|
parameters:
|
||||||
- name: data
|
- name: data
|
||||||
in: body
|
in: body
|
||||||
@@ -211,11 +389,11 @@ paths:
|
|||||||
'200':
|
'200':
|
||||||
description: The user identified by the API token
|
description: The user identified by the API token
|
||||||
schema:
|
schema:
|
||||||
$ref: '#!/definitions/User'
|
$ref: '#/definitions/User'
|
||||||
/authorizations/cookie/{cookie_name}/{cookie_value}:
|
/authorizations/cookie/{cookie_name}/{cookie_value}:
|
||||||
get:
|
get:
|
||||||
summary: Identify a user from a cookie
|
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:
|
parameters:
|
||||||
- name: cookie_name
|
- name: cookie_name
|
||||||
in: path
|
in: path
|
||||||
@@ -229,10 +407,19 @@ paths:
|
|||||||
'200':
|
'200':
|
||||||
description: The user identified by the cookie
|
description: The user identified by the cookie
|
||||||
schema:
|
schema:
|
||||||
$ref: '#!/definitions/User'
|
$ref: '#/definitions/User'
|
||||||
/shutdown:
|
/shutdown:
|
||||||
post:
|
post:
|
||||||
summary: Shutdown the Hub
|
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:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Hub has shutdown
|
description: Hub has shutdown
|
||||||
@@ -246,14 +433,53 @@ definitions:
|
|||||||
admin:
|
admin:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Whether the user is an admin
|
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:
|
server:
|
||||||
type: string
|
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:
|
pending:
|
||||||
type: string
|
type: string
|
||||||
enum: ["spawn", "stop"]
|
enum: ["spawn", "stop"]
|
||||||
description: The currently pending action, if any
|
description: The currently pending action, if any
|
||||||
last_activity:
|
last_activity:
|
||||||
type: string
|
type: string
|
||||||
format: ISO8601 Timestamp
|
format: date-time
|
||||||
description: Timestamp of last-seen activity from the user
|
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|
|
:Release: |release|
|
||||||
:Date: |today|
|
: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::
|
.. toctree::
|
||||||
|
|
||||||
auth
|
auth
|
||||||
spawner
|
spawner
|
||||||
user
|
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
|
.. 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
|
.. autoclass:: LocalProcessSpawner
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Writing a custom Authenticator
|
# Authenticators
|
||||||
|
|
||||||
The [Authenticator][] is the mechanism for authorizing users.
|
The [Authenticator][] is the mechanism for authorizing users.
|
||||||
Basic authenticators use simple username and password authentication.
|
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,
|
Because the username is passed from the Authenticator to the Spawner,
|
||||||
a custom Authenticator and Spawner are often used together.
|
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
|
## Basics of Authenticators
|
||||||
|
|
||||||
A basic Authenticator has one central method:
|
A basic Authenticator has one central method:
|
||||||
|
|
||||||
|
|
||||||
### Authenticator.authenticate
|
### Authenticator.authenticate
|
||||||
|
|
||||||
Authenticator.authenticate(handler, data)
|
Authenticator.authenticate(handler, data)
|
||||||
@@ -55,7 +54,6 @@ class DictionaryAuthenticator(Authenticator):
|
|||||||
return data['username']
|
return data['username']
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### Authenticator.whitelist
|
### Authenticator.whitelist
|
||||||
|
|
||||||
Authenticators can specify a whitelist of usernames to allow authentication.
|
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 = {
|
c.Authenticator.username_map = {
|
||||||
'service-name': 'localname'
|
'service-name': 'localname'
|
||||||
}
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Validating usernames
|
### Validating usernames
|
||||||
|
|
||||||
@@ -90,8 +89,9 @@ which is a regular expression string for validation.
|
|||||||
|
|
||||||
To only allow usernames that start with 'w':
|
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
|
## 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][].
|
at [OAuthenticator][].
|
||||||
|
|
||||||
|
|
||||||
[Authenticator]: https://github.com/jupyter/jupyterhub/blob/master/jupyterhub/auth.py
|
## 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
|
[PAM]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
|
||||||
[OAuth]: https://en.wikipedia.org/wiki/OAuth
|
[OAuth]: https://en.wikipedia.org/wiki/OAuth
|
||||||
[GitHub OAuth]: https://developer.github.com/v3/oauth/
|
[GitHub OAuth]: https://developer.github.com/v3/oauth/
|
||||||
[OAuthenticator]: https://github.com/jupyter/oauthenticator
|
[OAuthenticator]: https://github.com/jupyterhub/oauthenticator
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,132 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
## 0.3.0
|
|
||||||
|
## [Unreleased] 0.8
|
||||||
|
|
||||||
|
## 0.7
|
||||||
|
|
||||||
|
### [0.7.2] - 2017-01-09
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- Support service environment variables and defaults in `jupyterhub-singleuser`
|
||||||
|
for easier deployment of notebook servers as a Service.
|
||||||
|
- Add `--group` parameter for deploying `jupyterhub-singleuser` as a Service with group authentication.
|
||||||
|
- Include URL parameters when redirecting through `/user-redirect/`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix group authentication for HubAuthenticated services
|
||||||
|
|
||||||
|
### [0.7.1] - 2017-01-02
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- `Spawner.will_resume` for signaling 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] - 2016-02-03
|
||||||
|
|
||||||
|
Fix removal of `/login` page in 0.4.0, breaking some OAuth providers.
|
||||||
|
|
||||||
|
### [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.
|
||||||
|
- Add `Authenticator.pre_spawn_start` and `Authenticator.post_spawn_stop` hooks,
|
||||||
|
so that Authenticators can do setup or teardown (e.g. passing credentials to Spawner,
|
||||||
|
mounting data sources, etc.).
|
||||||
|
These methods are typically used with custom Authenticator+Spawner pairs.
|
||||||
|
- 0.4 will be the last JupyterHub release where single-user servers running IPython 3 is supported instead of Notebook ≥ 4.0.
|
||||||
|
|
||||||
|
|
||||||
|
## [0.3] - 2015-11-04
|
||||||
|
|
||||||
- No longer make the user starting the Hub an admin
|
- No longer make the user starting the Hub an admin
|
||||||
- start PAM sessions on login
|
- start PAM sessions on login
|
||||||
@@ -10,13 +134,24 @@ See `git log` for a more detailed summary.
|
|||||||
allowing deeper interaction between Spawner/Authenticator pairs.
|
allowing deeper interaction between Spawner/Authenticator pairs.
|
||||||
- login redirect fixes
|
- login redirect fixes
|
||||||
|
|
||||||
## 0.2.0
|
## [0.2] - 2015-07-12
|
||||||
|
|
||||||
- Based on standalone traitlets instead of IPython.utils.traitlets
|
- Based on standalone traitlets instead of IPython.utils.traitlets
|
||||||
- multiple users in admin panel
|
- multiple users in admin panel
|
||||||
- Fixes for usernames that require escaping
|
- Fixes for usernames that require escaping
|
||||||
|
|
||||||
## 0.1.0
|
## 0.1 - 2015-03-07
|
||||||
|
|
||||||
First preview release
|
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 -*-
|
# -*- 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 sys
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
|
|
||||||
# Needed for conversion from markdown to html
|
# For conversion from markdown to html
|
||||||
import recommonmark.parser
|
import recommonmark.parser
|
||||||
|
|
||||||
# If extensions (or modules to document with autodoc) are in another directory,
|
# Set paths
|
||||||
# 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.
|
|
||||||
#sys.path.insert(0, os.path.abspath('.'))
|
#sys.path.insert(0, os.path.abspath('.'))
|
||||||
|
|
||||||
# -- General configuration ------------------------------------------------
|
# -- General configuration ------------------------------------------------
|
||||||
|
|
||||||
# If your documentation needs a minimal Sphinx version, state it here.
|
# Minimal Sphinx version
|
||||||
needs_sphinx = '1.3'
|
needs_sphinx = '1.4'
|
||||||
|
|
||||||
# Add any Sphinx extension module names here, as strings. They can be
|
# Sphinx extension modules
|
||||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
|
||||||
# ones.
|
|
||||||
extensions = [
|
extensions = [
|
||||||
'sphinx.ext.autodoc',
|
'sphinx.ext.autodoc',
|
||||||
'sphinx.ext.intersphinx',
|
'sphinx.ext.intersphinx',
|
||||||
'sphinx.ext.napoleon',
|
'sphinx.ext.napoleon',
|
||||||
]
|
]
|
||||||
|
|
||||||
# Add any paths that contain templates here, relative to this directory.
|
|
||||||
templates_path = ['_templates']
|
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.
|
# The master toctree document.
|
||||||
master_doc = 'index'
|
master_doc = 'index'
|
||||||
|
|
||||||
@@ -62,12 +32,10 @@ project = u'JupyterHub'
|
|||||||
copyright = u'2016, Project Jupyter team'
|
copyright = u'2016, Project Jupyter team'
|
||||||
author = u'Project Jupyter team'
|
author = u'Project Jupyter team'
|
||||||
|
|
||||||
# The version info for the project you're documenting, acts as replacement for
|
# Autopopulate version
|
||||||
# |version| and |release|, also used in various other places throughout the
|
|
||||||
# built documents.
|
|
||||||
# Project Jupyter uses the following to autopopulate version
|
|
||||||
from os.path import dirname
|
from os.path import dirname
|
||||||
root = dirname(dirname(dirname(__file__)))
|
docs = dirname(dirname(__file__))
|
||||||
|
root = dirname(docs)
|
||||||
sys.path.insert(0, root)
|
sys.path.insert(0, root)
|
||||||
|
|
||||||
import jupyterhub
|
import jupyterhub
|
||||||
@@ -76,162 +44,59 @@ version = '%i.%i' % jupyterhub.version_info[:2]
|
|||||||
# The full version, including alpha/beta/rc tags.
|
# The full version, including alpha/beta/rc tags.
|
||||||
release = jupyterhub.__version__
|
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
|
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 = []
|
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'
|
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
|
todo_include_todos = False
|
||||||
|
|
||||||
|
# -- Source -------------------------------------------------------------
|
||||||
|
|
||||||
|
source_parsers = {
|
||||||
|
'.md': 'recommonmark.parser.CommonMarkParser',
|
||||||
|
}
|
||||||
|
|
||||||
|
source_suffix = ['.rst', '.md']
|
||||||
|
#source_encoding = 'utf-8-sig'
|
||||||
|
|
||||||
# -- Options for HTML output ----------------------------------------------
|
# -- Options for HTML output ----------------------------------------------
|
||||||
|
|
||||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
# The theme to use for HTML and HTML Help pages.
|
||||||
# a list of builtin themes.
|
|
||||||
html_theme = 'sphinx_rtd_theme'
|
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 = {}
|
#html_theme_options = {}
|
||||||
|
|
||||||
# Add any paths that contain custom themes here, relative to this directory.
|
|
||||||
#html_theme_path = []
|
#html_theme_path = []
|
||||||
|
|
||||||
# The name for this set of Sphinx documents. If None, it defaults to
|
|
||||||
# "<project> v<release> documentation".
|
|
||||||
#html_title = None
|
#html_title = None
|
||||||
|
|
||||||
# A shorter title for the navigation bar. Default is the same as html_title.
|
|
||||||
#html_short_title = None
|
#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
|
#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
|
#html_favicon = None
|
||||||
|
|
||||||
# Add any paths that contain custom static files (such as style sheets) here,
|
# Paths that contain custom static files (such as style sheets)
|
||||||
# relative to this directory. They are copied after the builtin static files,
|
|
||||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
|
||||||
html_static_path = ['_static']
|
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 = []
|
#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'
|
#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
|
#html_use_smartypants = True
|
||||||
|
|
||||||
# Custom sidebar templates, maps document names to template names.
|
|
||||||
#html_sidebars = {}
|
#html_sidebars = {}
|
||||||
|
|
||||||
# Additional templates that should be rendered to pages, maps page names to
|
|
||||||
# template names.
|
|
||||||
#html_additional_pages = {}
|
#html_additional_pages = {}
|
||||||
|
|
||||||
# If false, no module index is generated.
|
|
||||||
#html_domain_indices = True
|
#html_domain_indices = True
|
||||||
|
|
||||||
# If false, no index is generated.
|
|
||||||
#html_use_index = True
|
#html_use_index = True
|
||||||
|
|
||||||
# If true, the index is split into individual pages for each letter.
|
|
||||||
#html_split_index = False
|
#html_split_index = False
|
||||||
|
|
||||||
# If true, links to the reST sources are added to the pages.
|
|
||||||
#html_show_sourcelink = True
|
#html_show_sourcelink = True
|
||||||
|
|
||||||
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
|
|
||||||
#html_show_sphinx = True
|
#html_show_sphinx = True
|
||||||
|
|
||||||
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
|
|
||||||
#html_show_copyright = 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 = ''
|
#html_use_opensearch = ''
|
||||||
|
|
||||||
# This is the file name suffix for HTML files (e.g. ".xhtml").
|
|
||||||
#html_file_suffix = None
|
#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'
|
#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'}
|
#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'
|
#html_search_scorer = 'scorer.js'
|
||||||
|
|
||||||
# Output file base name for HTML help builder.
|
|
||||||
htmlhelp_basename = 'JupyterHubdoc'
|
htmlhelp_basename = 'JupyterHubdoc'
|
||||||
|
|
||||||
# -- Options for LaTeX output ---------------------------------------------
|
# -- Options for LaTeX output ---------------------------------------------
|
||||||
|
|
||||||
latex_elements = {
|
latex_elements = {
|
||||||
# The paper size ('letterpaper' or 'a4paper').
|
|
||||||
#'papersize': 'letterpaper',
|
#'papersize': 'letterpaper',
|
||||||
|
|
||||||
# The font size ('10pt', '11pt' or '12pt').
|
|
||||||
#'pointsize': '10pt',
|
#'pointsize': '10pt',
|
||||||
|
|
||||||
# Additional stuff for the LaTeX preamble.
|
|
||||||
#'preamble': '',
|
#'preamble': '',
|
||||||
|
|
||||||
# Latex figure (float) alignment
|
|
||||||
#'figure_align': 'htbp',
|
#'figure_align': 'htbp',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,28 +108,15 @@ latex_documents = [
|
|||||||
u'Project Jupyter team', 'manual'),
|
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
|
#latex_logo = None
|
||||||
|
|
||||||
# For "manual" documents, if this is true, then toplevel headings are parts,
|
|
||||||
# not chapters.
|
|
||||||
#latex_use_parts = False
|
#latex_use_parts = False
|
||||||
|
|
||||||
# If true, show page references after internal links.
|
|
||||||
#latex_show_pagerefs = False
|
#latex_show_pagerefs = False
|
||||||
|
|
||||||
# If true, show URL addresses after external links.
|
|
||||||
#latex_show_urls = False
|
#latex_show_urls = False
|
||||||
|
|
||||||
# Documents to append as an appendix to all manuals.
|
|
||||||
#latex_appendices = []
|
#latex_appendices = []
|
||||||
|
|
||||||
# If false, no module index is generated.
|
|
||||||
#latex_domain_indices = True
|
#latex_domain_indices = True
|
||||||
|
|
||||||
|
|
||||||
# -- Options for manual page output ---------------------------------------
|
# -- manual page output -------------------------------------------------
|
||||||
|
|
||||||
# One entry per manual page. List of tuples
|
# One entry per manual page. List of tuples
|
||||||
# (source start file, name, description, authors, manual section).
|
# (source start file, name, description, authors, manual section).
|
||||||
@@ -273,11 +125,10 @@ man_pages = [
|
|||||||
[author], 1)
|
[author], 1)
|
||||||
]
|
]
|
||||||
|
|
||||||
# If true, show URL addresses after external links.
|
|
||||||
#man_show_urls = False
|
#man_show_urls = False
|
||||||
|
|
||||||
|
|
||||||
# -- Options for Texinfo output -------------------------------------------
|
# -- Texinfo output -----------------------------------------------------
|
||||||
|
|
||||||
# Grouping the document tree into Texinfo files. List of tuples
|
# Grouping the document tree into Texinfo files. List of tuples
|
||||||
# (source start file, target name, title, author,
|
# (source start file, target name, title, author,
|
||||||
@@ -288,20 +139,13 @@ texinfo_documents = [
|
|||||||
'Miscellaneous'),
|
'Miscellaneous'),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Documents to append as an appendix to all manuals.
|
|
||||||
#texinfo_appendices = []
|
#texinfo_appendices = []
|
||||||
|
|
||||||
# If false, no module index is generated.
|
|
||||||
#texinfo_domain_indices = True
|
#texinfo_domain_indices = True
|
||||||
|
|
||||||
# How to display URL addresses: 'footnote', 'no', or 'inline'.
|
|
||||||
#texinfo_show_urls = 'footnote'
|
#texinfo_show_urls = 'footnote'
|
||||||
|
|
||||||
# If true, do not generate a @detailmenu in the "Top" node's menu.
|
|
||||||
#texinfo_no_detailmenu = False
|
#texinfo_no_detailmenu = False
|
||||||
|
|
||||||
|
|
||||||
# -- Options for Epub output ----------------------------------------------
|
# -- Epub output --------------------------------------------------------
|
||||||
|
|
||||||
# Bibliographic Dublin Core info.
|
# Bibliographic Dublin Core info.
|
||||||
epub_title = project
|
epub_title = project
|
||||||
@@ -309,78 +153,35 @@ epub_author = author
|
|||||||
epub_publisher = author
|
epub_publisher = author
|
||||||
epub_copyright = copyright
|
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.
|
# A list of files that should not be packed into the epub file.
|
||||||
epub_exclude_files = ['search.html']
|
epub_exclude_files = ['search.html']
|
||||||
|
|
||||||
# The depth of the table of contents in toc.ncx.
|
# -- Intersphinx ----------------------------------------------------------
|
||||||
#epub_tocdepth = 3
|
|
||||||
|
|
||||||
# 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}
|
intersphinx_mapping = {'https://docs.python.org/': None}
|
||||||
|
|
||||||
# Read The Docs
|
# -- Read The Docs --------------------------------------------------------
|
||||||
# on_rtd is whether we are on readthedocs.org, this line of code grabbed from docs.readthedocs.org
|
|
||||||
on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
|
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
|
import sphinx_rtd_theme
|
||||||
html_theme = 'sphinx_rtd_theme'
|
html_theme = 'sphinx_rtd_theme'
|
||||||
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
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
|
# Getting started with JupyterHub
|
||||||
|
|
||||||
This document describes some of the basics of configuring JupyterHub to do what you want.
|
This section contains getting started information on the following topics:
|
||||||
JupyterHub is highly customizable, so there's a lot to cover.
|
|
||||||
|
- [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.
|
### Basic operation
|
||||||
There are three main categories of processes run by the `jupyterhub` command line program:
|
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
|
Basic principles of operation:
|
||||||
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*.
|
|
||||||
|
|
||||||
## 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:
|
To start JupyterHub in its default configuration, type the following at the command line:
|
||||||
|
|
||||||
|
```bash
|
||||||
sudo jupyterhub
|
sudo jupyterhub
|
||||||
|
```
|
||||||
|
|
||||||
The default Authenticator that ships with JupyterHub authenticates users
|
The default Authenticator that ships with JupyterHub authenticates users
|
||||||
with their system name and password (via [PAM][]).
|
with their system name and password (via [PAM][]).
|
||||||
@@ -34,94 +92,139 @@ Any user on the system with a password will be allowed to start a single-user no
|
|||||||
The default Spawner starts servers locally as each user, one dedicated server per user.
|
The default Spawner starts servers locally as each user, one dedicated server per user.
|
||||||
These servers listen on localhost, and start in the given user's home directory.
|
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.
|
By default, the **Proxy** listens on all public interfaces on port 8000.
|
||||||
Thus you can reach JupyterHub through:
|
Thus you can reach JupyterHub through either:
|
||||||
|
|
||||||
http://localhost:8000
|
- `http://localhost:8000`
|
||||||
|
- or any other public IP or domain pointing to your system.
|
||||||
|
|
||||||
or any other public IP or domain pointing to your system.
|
In their default configuration, the other services, the **Hub** and **Single-User Servers**,
|
||||||
|
|
||||||
In their default configuration, the other services, the *Hub* and *Single-User Servers*,
|
|
||||||
all communicate with each other on localhost only.
|
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 [below](#Security) for how to configure JupyterHub to use SSL.
|
|
||||||
|
|
||||||
By default, starting JupyterHub will write two files to disk in the current working directory:
|
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*.
|
- `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,
|
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.
|
- `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.
|
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.
|
Conversely, deleting this file and restarting the server effectively invalidates all login cookies.
|
||||||
The cookie secret file is discussed [below](#Security).
|
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.
|
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:
|
JupyterHub is configured in two ways:
|
||||||
|
|
||||||
1. Command-line arguments
|
1. Configuration file
|
||||||
2. Configuration files
|
2. Command-line arguments
|
||||||
|
|
||||||
Type the following for brief information about the command line arguments:
|
### Configuration file
|
||||||
|
By default, JupyterHub will look for a configuration file (which may not be created yet)
|
||||||
jupyterhub -h
|
|
||||||
|
|
||||||
or:
|
|
||||||
|
|
||||||
jupyterhub --help-all
|
|
||||||
|
|
||||||
for the full command line help.
|
|
||||||
|
|
||||||
By default, JupyterHub will look for a configuration file (can be missing)
|
|
||||||
named `jupyterhub_config.py` in the current working directory.
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
jupyterhub --generate-config
|
jupyterhub --generate-config
|
||||||
|
```
|
||||||
|
|
||||||
This empty configuration file has descriptions of all configuration variables and their default
|
This empty configuration file has descriptions of all configuration variables and their default
|
||||||
values. You can load a specific config file with:
|
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)
|
See also: [general docs](http://ipython.org/ipython-doc/dev/development/config.html)
|
||||||
on the config system Jupyter uses.
|
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
|
## Networking
|
||||||
|
|
||||||
In most situations you will want to change the main IP address and port of the Proxy.
|
### Configuring the Proxy's IP address and port
|
||||||
This address determines where JupyterHub is available to your users.
|
The Proxy's main IP address setting determines where JupyterHub is available to users.
|
||||||
The default is all network interfaces (`''`) on port 8000.
|
By default, JupyterHub is configured to be available on all network interfaces
|
||||||
|
(`''`) on port 8000. **Note**: Use of `'*'` is discouraged for IP configuration;
|
||||||
|
instead, use of `'0.0.0.0'` is preferred.
|
||||||
|
|
||||||
This can be done with the following command line arguments:
|
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 you can put the following lines in a configuration file:
|
Or by placing the following lines in a configuration file:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
c.JupyterHub.ip = '192.168.1.2'
|
c.JupyterHub.ip = '192.168.1.2'
|
||||||
c.JupyterHub.port = 443
|
c.JupyterHub.port = 443
|
||||||
```
|
```
|
||||||
|
|
||||||
Port 443 is used in these examples as it is the default port for SSL/HTTPS.
|
Port 443 is used as an example since 443 is the default port for SSL/HTTPS.
|
||||||
|
|
||||||
Configuring only the main IP and port of JupyterHub should be sufficient for most deployments of JupyterHub.
|
Configuring only the main IP and port of JupyterHub should be sufficient for most deployments of JupyterHub.
|
||||||
However, for more customized scenarios,
|
However, more customized scenarios may need additional networking details to
|
||||||
you can configure the following additional networking details.
|
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,
|
The Hub service talks to the proxy via a REST API on a secondary port,
|
||||||
whose network interface and port can be configured separately.
|
whose network interface and port can be configured separately.
|
||||||
By default, this REST API listens on port 8081 of localhost only.
|
By default, this REST API listens on port 8081 of localhost only.
|
||||||
If you want to run the Proxy separate from the Hub,
|
|
||||||
you may need to configure this IP and port with:
|
If running the Proxy separate from the Hub,
|
||||||
|
configure the REST API communication IP address and port with:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# ideally a private network address
|
# ideally a private network address
|
||||||
@@ -129,11 +232,12 @@ c.JupyterHub.proxy_api_ip = '10.0.1.4'
|
|||||||
c.JupyterHub.proxy_api_port = 5432
|
c.JupyterHub.proxy_api_port = 5432
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Configuring the Hub if Spawners or Proxy are remote or isolated in containers
|
||||||
The Hub service also listens only on localhost (port 8080) by default.
|
The Hub service also listens only on localhost (port 8080) by default.
|
||||||
The Hub needs needs to be accessible from both the proxy and all Spawners.
|
The Hub needs needs to be accessible from both the proxy and all Spawners.
|
||||||
When spawning local servers localhost is fine,
|
When spawning local servers, an IP address setting of localhost is fine.
|
||||||
but if *either* the Proxy or (more likely) the Spawners will be remote or isolated in containers,
|
If *either* the Proxy *or* (more likely) the Spawners will be remote or
|
||||||
the Hub must listen on an IP that is accessible.
|
isolated in containers, the Hub must listen on an IP that is accessible.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
c.JupyterHub.hub_ip = '10.0.1.4'
|
c.JupyterHub.hub_ip = '10.0.1.4'
|
||||||
@@ -142,14 +246,31 @@ c.JupyterHub.hub_port = 54321
|
|||||||
|
|
||||||
## Security
|
## 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:
|
security configuration:
|
||||||
|
|
||||||
1. SSL encryption (to enable HTTPS)
|
1. SSL encryption (to enable HTTPS)
|
||||||
2. Cookie secret (a key for encrypting browser cookies)
|
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)
|
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
|
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
|
it without SSL (HTTPS). This will require you to obtain an official, trusted SSL certificate or
|
||||||
@@ -161,19 +282,35 @@ c.JupyterHub.ssl_key = '/path/to/my.key'
|
|||||||
c.JupyterHub.ssl_cert = '/path/to/my.cert'
|
c.JupyterHub.ssl_cert = '/path/to/my.cert'
|
||||||
```
|
```
|
||||||
|
|
||||||
It is also possible to use letsencrypt (https://letsencrypt.org/) to obtain a free, trusted SSL
|
It is also possible to use letsencrypt (https://letsencrypt.org/) to obtain
|
||||||
certificate. If you run letsencrypt using the default options, the needed configuration is (replace `your.domain.com` by your fully qualified domain name):
|
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
|
```python
|
||||||
c.JupyterHub.ssl_key = '/etc/letsencrypt/live/your.domain.com/privkey.pem'
|
c.JupyterHub.ssl_key = '/etc/letsencrypt/live/{mydomain.tld}/privkey.pem'
|
||||||
c.JupyterHub.ssl_cert = '/etc/letsencrypt/live/your.domain.com/fullchain.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
|
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
|
these files be put in a secure location on your server, where they are not readable by regular
|
||||||
users.
|
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
|
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.
|
authentication. If this value changes for the Hub, all single-user servers must also be restarted.
|
||||||
@@ -184,29 +321,45 @@ as follows:
|
|||||||
c.JupyterHub.cookie_secret_file = '/srv/jupyterhub/cookie_secret'
|
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
|
The content of this file should be a long random string encoded in MIME Base64. An example would be to generate this file as:
|
||||||
file as:
|
|
||||||
|
|
||||||
```bash
|
```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
|
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
|
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 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
|
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
|
```bash
|
||||||
export JPY_COOKIE_SECRET=`openssl rand -hex 1024`
|
export JPY_COOKIE_SECRET=`openssl rand -hex 1024`
|
||||||
```
|
```
|
||||||
|
|
||||||
For security reasons, this environment variable should only be visible to the Hub.
|
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
|
```bash
|
||||||
export CONFIGPROXY_AUTH_TOKEN=`openssl rand -hex 32`
|
export CONFIGPROXY_AUTH_TOKEN=`openssl rand -hex 32`
|
||||||
@@ -214,7 +367,7 @@ export CONFIGPROXY_AUTH_TOKEN=`openssl rand -hex 32`
|
|||||||
|
|
||||||
This environment variable needs to be visible to the Hub and Proxy.
|
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
|
```python
|
||||||
c.JupyterHub.proxy_auth_token = '0bc02bede919e99a26de1e2a7a5aadfaf6228de836ec39a05a6c6942831d8fe5'
|
c.JupyterHub.proxy_auth_token = '0bc02bede919e99a26de1e2a7a5aadfaf6228de836ec39a05a6c6942831d8fe5'
|
||||||
@@ -224,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
|
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).
|
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`:
|
You can restrict which users are allowed to login with `Authenticator.whitelist`:
|
||||||
|
|
||||||
|
|
||||||
@@ -237,6 +405,11 @@ You can restrict which users are allowed to login with `Authenticator.whitelist`
|
|||||||
c.Authenticator.whitelist = {'mal', 'zoe', 'inara', 'kaylee'}
|
c.Authenticator.whitelist = {'mal', 'zoe', 'inara', 'kaylee'}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Users listed in the whitelist are added to the Hub database when the Hub is
|
||||||
|
started.
|
||||||
|
|
||||||
|
### Managing Hub administrators
|
||||||
|
|
||||||
Admin users of JupyterHub have the ability to take actions on users' behalf,
|
Admin users of JupyterHub have the ability to take actions on users' behalf,
|
||||||
such as stopping and restarting their servers,
|
such as stopping and restarting their servers,
|
||||||
and adding and removing new users from the whitelist.
|
and adding and removing new users from the whitelist.
|
||||||
@@ -252,14 +425,22 @@ 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.
|
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.**
|
**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).
|
||||||
|
|
||||||
Users can be added and removed to the Hub via the admin panel or REST API. These users will be
|
### Add or remove users from the Hub
|
||||||
added to the whitelist and database. Restarting the Hub will not require manually updating the
|
|
||||||
whitelist in your config file, as the users will be loaded from the database. This means that
|
Users can be added to and removed from the Hub via either the admin panel or
|
||||||
after starting the Hub once, it is not sufficient to remove users from the whitelist in your
|
REST API.
|
||||||
config file. You must also remove them from the database, either by discarding the database file,
|
|
||||||
or via the admin UI.
|
If a user is **added**, the user will be automatically added to the whitelist
|
||||||
|
and database. Restarting the Hub will not require manually updating the
|
||||||
|
whitelist in your config file, as the users will be loaded from the database.
|
||||||
|
|
||||||
|
After starting the Hub once, it is not sufficient to **remove** a user from
|
||||||
|
the whitelist in your config file. You must also remove the user from the Hub's
|
||||||
|
database, either by deleting the user from the admin page, or you can clear
|
||||||
|
the `jupyterhub.sqlite` database and start fresh.
|
||||||
|
|
||||||
The default `PAMAuthenticator` is one case of a special kind of authenticator, called a
|
The default `PAMAuthenticator` is one case of a special kind of authenticator, called a
|
||||||
`LocalAuthenticator`, indicating that it manages users on the local system. When you add a user to
|
`LocalAuthenticator`, indicating that it manages users on the local system. When you add a user to
|
||||||
@@ -276,7 +457,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
|
launching the service. It is not recommended when running JupyterHub in situations where
|
||||||
JupyterHub users maps directly onto UNIX users.
|
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
|
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
|
multi-process application, there are many aspect of that server can configure, and a lot of ways
|
||||||
@@ -312,96 +493,42 @@ which is the place to put configuration that you want to affect all of your user
|
|||||||
|
|
||||||
## External services
|
## External services
|
||||||
|
|
||||||
JupyterHub has a REST API that can be used to run external services.
|
JupyterHub has a REST API that can be used by external services like the
|
||||||
More detail on this API will be added in the future.
|
[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
|
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:
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export GITHUB_CLIENT_ID=github_id
|
jupyterhub token <username>
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
# Further reading
|
As of [version 0.6.0](./changelog.html), the preferred way of doing this is to first generate an API token:
|
||||||
|
|
||||||
- TODO: troubleshooting
|
```bash
|
||||||
- [Custom Authenticators](./authenticators.html)
|
openssl rand -hex 32
|
||||||
- [Custom Spawners](./spawners.html)
|
```
|
||||||
|
|
||||||
|
|
||||||
[oauth-setup]: https://github.com/jupyter/oauthenticator#setup
|
and then write it to your JupyterHub configuration file (note that the **key** is the token while the **value** is the username):
|
||||||
[oauthenticator]: https://github.com/jupyter/oauthenticator
|
|
||||||
|
```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
|
[PAM]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# How JupyterHub works
|
# 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:
|
There are three basic processes involved:
|
||||||
|
|
||||||
- multi-user Hub (Python/Tornado)
|
- multi-user Hub (Python/Tornado)
|
||||||
- configurable http proxy (nodejs)
|
- [configurable http proxy](https://github.com/jupyterhub/configurable-http-proxy) (node-http-proxy)
|
||||||
- multiple single-user IPython notebook servers (Python/IPython/Tornado)
|
- multiple single-user IPython notebook servers (Python/IPython/Tornado)
|
||||||
|
|
||||||
The proxy is the only process that listens on a public interface.
|
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
|
which regular users typically do not have
|
||||||
(on Ubuntu, this requires being added to the `shadow` group).
|
(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
|
### Spawning
|
||||||
@@ -72,6 +72,6 @@ and needs to be able to take three actions:
|
|||||||
2. poll whether the process is still running
|
2. poll whether the process is still running
|
||||||
3. stop the process
|
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,80 +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
|
JupyterHub
|
||||||
==========
|
==========
|
||||||
|
|
||||||
.. note:: This is the official documentation for JupyterHub. This project is
|
With JupyterHub you can create a **multi-user Hub** which spawns, manages,
|
||||||
under active development.
|
and proxies multiple instances of the single-user
|
||||||
|
`Jupyter notebook <https://jupyter-notebook.readthedocs.io/en/latest/>`_ server.
|
||||||
JupyterHub is a multi-user server that manages and proxies multiple instances
|
Due to its flexibility and customization options, JupyterHub can be used to
|
||||||
of the single-user Jupyter notebook server.
|
serve notebooks to a class of students, a corporate data science group, or a
|
||||||
|
scientific research group.
|
||||||
Three actors:
|
|
||||||
|
|
||||||
* multi-user Hub (tornado process)
|
|
||||||
* 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
|
|
||||||
|
|
||||||
|
|
||||||
Contents:
|
.. image:: images/jhub-parts.png
|
||||||
|
:alt: JupyterHub subsystems
|
||||||
|
:width: 40%
|
||||||
|
:align: right
|
||||||
|
|
||||||
.. toctree::
|
|
||||||
:maxdepth: 1
|
|
||||||
:caption: User Documentation
|
|
||||||
|
|
||||||
getting-started
|
Three subsystems make up JupyterHub:
|
||||||
howitworks
|
|
||||||
|
* 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::
|
.. toctree::
|
||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
:caption: Configuration
|
: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
|
||||||
|
:hidden:
|
||||||
|
:caption: Configuration Guide
|
||||||
|
|
||||||
authenticators
|
authenticators
|
||||||
spawners
|
spawners
|
||||||
|
services
|
||||||
|
config-examples
|
||||||
|
upgrading
|
||||||
|
troubleshooting
|
||||||
|
|
||||||
|
|
||||||
|
**API Reference**
|
||||||
|
|
||||||
|
* :doc:`api/index`
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 1
|
:maxdepth: 2
|
||||||
:caption: Developer Documentation
|
:hidden:
|
||||||
|
:caption: API Reference
|
||||||
|
|
||||||
api/index
|
api/index
|
||||||
|
|
||||||
|
|
||||||
.. toctree::
|
**About JupyterHub**
|
||||||
:maxdepth: 1
|
|
||||||
:caption: Community documentation
|
|
||||||
|
|
||||||
|
|
||||||
|
* :doc:`changelog`
|
||||||
|
* :doc:`contributor-list`
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
|
:hidden:
|
||||||
:caption: About JupyterHub
|
:caption: About JupyterHub
|
||||||
|
|
||||||
changelog
|
changelog
|
||||||
|
contributor-list
|
||||||
.. 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>
|
|
||||||
|
|
||||||
|
|
||||||
Indices and tables
|
Indices and tables
|
||||||
==================
|
------------------
|
||||||
|
|
||||||
* :ref:`genindex`
|
* :ref:`genindex`
|
||||||
* :ref:`modindex`
|
* :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.
|
A [Spawner][] starts each single-user notebook server.
|
||||||
The Spawner represents an abstract interface to a process,
|
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
|
- poll whether the process is still running
|
||||||
- stop the process
|
- stop the process
|
||||||
|
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
Custom Spawners for JupyterHub can be found on the [JupyterHub wiki](https://github.com/jupyter/jupyterhub/wiki/Spawners). Some examples include:
|
Custom Spawners for JupyterHub can be found on the [JupyterHub wiki](https://github.com/jupyterhub/jupyterhub/wiki/Spawners).
|
||||||
- [DockerSpawner](https://github.com/jupyter/dockerspawner) for spawning user servers in Docker containers
|
Some examples include:
|
||||||
* dockerspawner.DockerSpawner for spawning identical Docker containers for
|
|
||||||
|
- [DockerSpawner](https://github.com/jupyterhub/dockerspawner) for spawning user servers in Docker containers
|
||||||
|
* `dockerspawner.DockerSpawner` for spawning identical Docker containers for
|
||||||
each users
|
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
|
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`
|
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
|
servers using batch systems
|
||||||
- [RemoteSpawner](https://github.com/zonca/remotespawner) to spawn notebooks
|
- [RemoteSpawner](https://github.com/zonca/remotespawner) to spawn notebooks
|
||||||
and a remote server and tunnel the port via SSH
|
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
|
## 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)`
|
For the local process case, `Spawner.poll` uses `os.kill(PID, 0)`
|
||||||
to check if the local process is still running.
|
to check if the local process is still running.
|
||||||
|
|
||||||
|
|
||||||
### Spawner.stop
|
### Spawner.stop
|
||||||
|
|
||||||
`Spawner.stop` should stop the process. It must be a tornado coroutine, which should return when the process has finished exiting.
|
`Spawner.stop` should stop the process. It must be a tornado coroutine, which should return when the process has finished exiting.
|
||||||
|
|
||||||
|
|
||||||
## Spawner state
|
## Spawner state
|
||||||
|
|
||||||
JupyterHub should be able to stop and restart without tearing down
|
JupyterHub should be able to stop and restart without tearing down
|
||||||
@@ -97,6 +101,7 @@ def clear_state(self):
|
|||||||
self.pid = 0
|
self.pid = 0
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## Spawner options form
|
## Spawner options form
|
||||||
|
|
||||||
(new in 0.4)
|
(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.
|
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`
|
### `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]: ../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
|
||||||
274
docs/source/troubleshooting.md
Normal file
274
docs/source/troubleshooting.md
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
[*Behavior*](#behavior)
|
||||||
|
- JupyterHub proxy fails to start
|
||||||
|
- sudospawner fails to run
|
||||||
|
|
||||||
|
[*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``
|
||||||
|
|
||||||
|
### 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)
|
- single-user websocket ping interval (default: 30s)
|
||||||
- JupyterHub.last_activity_interval (default: 5 minutes)
|
- 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
|
import datetime
|
||||||
@@ -34,7 +45,7 @@ def cull_idle(url, api_token, timeout):
|
|||||||
auth_header = {
|
auth_header = {
|
||||||
'Authorization': 'token %s' % api_token
|
'Authorization': 'token %s' % api_token
|
||||||
}
|
}
|
||||||
req = HTTPRequest(url=url + '/api/users',
|
req = HTTPRequest(url=url + '/users',
|
||||||
headers=auth_header,
|
headers=auth_header,
|
||||||
)
|
)
|
||||||
now = datetime.datetime.utcnow()
|
now = datetime.datetime.utcnow()
|
||||||
@@ -47,7 +58,7 @@ def cull_idle(url, api_token, timeout):
|
|||||||
last_activity = parse_date(user['last_activity'])
|
last_activity = parse_date(user['last_activity'])
|
||||||
if user['server'] and last_activity < cull_limit:
|
if user['server'] and last_activity < cull_limit:
|
||||||
app_log.info("Culling %s (inactive since %s)", user['name'], last_activity)
|
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',
|
method='DELETE',
|
||||||
headers=auth_header,
|
headers=auth_header,
|
||||||
)
|
)
|
||||||
@@ -60,7 +71,7 @@ def cull_idle(url, api_token, timeout):
|
|||||||
app_log.debug("Finished culling %s", name)
|
app_log.debug("Finished culling %s", name)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
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('timeout', default=600, help="The idle timeout (in seconds)")
|
||||||
define('cull_every', default=0, help="The interval (in seconds) for checking for idle servers to cull")
|
define('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:
|
if not options.cull_every:
|
||||||
options.cull_every = options.timeout // 2
|
options.cull_every = options.timeout // 2
|
||||||
|
|
||||||
api_token = os.environ['JPY_API_TOKEN']
|
api_token = os.environ['JUPYTERHUB_API_TOKEN']
|
||||||
|
|
||||||
loop = IOLoop.current()
|
loop = IOLoop.current()
|
||||||
cull = lambda : cull_idle(options.url, api_token, options.timeout)
|
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>
|
MAINTAINER Jupyter Project <jupyter@googlegroups.com>
|
||||||
|
|
||||||
|
|||||||
25
examples/service-notebook/README.md
Normal file
25
examples/service-notebook/README.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Running a shared notebook as a service
|
||||||
|
|
||||||
|
This directory contains two examples of running a shared notebook server as a service,
|
||||||
|
one as a 'managed' service, and one as an external service with supervisor.
|
||||||
|
|
||||||
|
These examples require jupyterhub >= 0.7.2.
|
||||||
|
|
||||||
|
A single-user notebook server is run as a service,
|
||||||
|
and uses groups to authenticate a collection of users with the Hub.
|
||||||
|
|
||||||
|
In these examples, a JupyterHub group `'shared'` is created,
|
||||||
|
and a notebook server is spawned at `/services/shared-notebook`.
|
||||||
|
Any user in the `'shared'` group will be able to access the notebook server at `/services/shared-notebook/`.
|
||||||
|
|
||||||
|
In both examples, you will want to select the name of the group,
|
||||||
|
and the name of the shared-notebook service.
|
||||||
|
|
||||||
|
In the external example, some extra steps are required to set up supervisor:
|
||||||
|
|
||||||
|
1. select a system user to run the service. This is a user on the system, and does not need to be a Hub user. Add this to the user field in `shared-notebook.conf`, replacing `someuser`.
|
||||||
|
2. generate a secret token for authentication, and replace the `super-secret` fields in `shared-notebook-service` and `jupyterhub_config.py`
|
||||||
|
3. install `shared-notebook-service` somewhere on your system, and update `/path/to/shared-notebook-service` to the absolute path of this destination
|
||||||
|
3. copy `shared-notebook.conf` to `/etc/supervisor/conf.d/`
|
||||||
|
4. `supervisorctl reload`
|
||||||
|
|
||||||
24
examples/service-notebook/external/jupyterhub_config.py
vendored
Normal file
24
examples/service-notebook/external/jupyterhub_config.py
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# our user list
|
||||||
|
c.Authenticator.whitelist = [
|
||||||
|
'minrk',
|
||||||
|
'ellisonbg',
|
||||||
|
'willingc',
|
||||||
|
]
|
||||||
|
|
||||||
|
# ellisonbg and willingc have access to a shared server:
|
||||||
|
|
||||||
|
c.JupyterHub.load_groups = {
|
||||||
|
'shared': [
|
||||||
|
'ellisonbg',
|
||||||
|
'willingc',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# start the notebook server as a service
|
||||||
|
c.JupyterHub.services = [
|
||||||
|
{
|
||||||
|
'name': 'shared-notebook',
|
||||||
|
'url': 'http://127.0.0.1:9999',
|
||||||
|
'api_token': 'super-secret',
|
||||||
|
}
|
||||||
|
]
|
||||||
9
examples/service-notebook/external/shared-notebook-service
vendored
Executable file
9
examples/service-notebook/external/shared-notebook-service
vendored
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash -l
|
||||||
|
set -e
|
||||||
|
|
||||||
|
export JUPYTERHUB_API_TOKEN=super-secret
|
||||||
|
export JUPYTERHUB_SERVICE_URL=http://127.0.0.1:9999
|
||||||
|
export JUPYTERHUB_SERVICE_NAME=shared-notebook
|
||||||
|
|
||||||
|
jupyterhub-singleuser \
|
||||||
|
--group='shared'
|
||||||
14
examples/service-notebook/external/shared-notebook.conf
vendored
Normal file
14
examples/service-notebook/external/shared-notebook.conf
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[program:jupyterhub-shared-notebook]
|
||||||
|
user=someuser
|
||||||
|
command=bash -l /path/to/shared-notebook-service
|
||||||
|
directory=/home/someuser
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
startretries=1
|
||||||
|
exitcodes=0,2
|
||||||
|
stopsignal=TERM
|
||||||
|
redirect_stderr=true
|
||||||
|
stdout_logfile=/var/log/jupyterhub-service-shared-notebook.log
|
||||||
|
stdout_logfile_maxbytes=1MB
|
||||||
|
stdout_logfile_backups=10
|
||||||
|
stdout_capture_maxbytes=1MB
|
||||||
32
examples/service-notebook/managed/jupyterhub_config.py
Normal file
32
examples/service-notebook/managed/jupyterhub_config.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# our user list
|
||||||
|
c.Authenticator.whitelist = [
|
||||||
|
'minrk',
|
||||||
|
'ellisonbg',
|
||||||
|
'willingc',
|
||||||
|
]
|
||||||
|
|
||||||
|
# ellisonbg and willingc have access to a shared server:
|
||||||
|
|
||||||
|
c.JupyterHub.load_groups = {
|
||||||
|
'shared': [
|
||||||
|
'ellisonbg',
|
||||||
|
'willingc',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
service_name = 'shared-notebook'
|
||||||
|
service_port = 9999
|
||||||
|
group_name = 'shared'
|
||||||
|
|
||||||
|
# start the notebook server as a service
|
||||||
|
c.JupyterHub.services = [
|
||||||
|
{
|
||||||
|
'name': service_name,
|
||||||
|
'url': 'http://127.0.0.1:{}'.format(service_port),
|
||||||
|
'command': [
|
||||||
|
'jupyterhub-singleuser',
|
||||||
|
'--group=shared',
|
||||||
|
'--debug',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
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
|
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 .base import *
|
||||||
from .auth import *
|
from . import auth, hub, proxy, users, groups, services
|
||||||
from .hub import *
|
|
||||||
from .proxy import *
|
|
||||||
from .users import *
|
|
||||||
|
|
||||||
from . import auth, hub, proxy, users
|
|
||||||
|
|
||||||
default_handlers = []
|
default_handlers = []
|
||||||
for mod in (auth, hub, proxy, users):
|
for mod in (auth, hub, proxy, users, groups, services):
|
||||||
default_handlers.extend(mod.default_handlers)
|
default_handlers.extend(mod.default_handlers)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import json
|
import json
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from tornado import web
|
from tornado import web, gen
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from ..utils import token_authenticated
|
from ..utils import token_authenticated
|
||||||
from .base import APIHandler
|
from .base import APIHandler
|
||||||
@@ -20,13 +20,25 @@ class TokenAPIHandler(APIHandler):
|
|||||||
raise web.HTTPError(404)
|
raise web.HTTPError(404)
|
||||||
self.write(json.dumps(self.user_model(self.users[orm_token.user])))
|
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):
|
class CookieAPIHandler(APIHandler):
|
||||||
@token_authenticated
|
@token_authenticated
|
||||||
def get(self, cookie_name, cookie_value=None):
|
def get(self, cookie_name, cookie_value=None):
|
||||||
cookie_name = quote(cookie_name, safe='')
|
cookie_name = quote(cookie_name, safe='')
|
||||||
if cookie_value is None:
|
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
|
cookie_value = self.request.body
|
||||||
else:
|
else:
|
||||||
cookie_value = cookie_value.encode('utf8')
|
cookie_value = cookie_value.encode('utf8')
|
||||||
@@ -39,4 +51,5 @@ class CookieAPIHandler(APIHandler):
|
|||||||
default_handlers = [
|
default_handlers = [
|
||||||
(r"/api/authorizations/cookie/([^/]+)(?:/([^/]+))?", CookieAPIHandler),
|
(r"/api/authorizations/cookie/([^/]+)(?:/([^/]+))?", CookieAPIHandler),
|
||||||
(r"/api/authorizations/token/([^/]+)", TokenAPIHandler),
|
(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.
|
# If no header is provided, assume it comes from a script/curl.
|
||||||
# We are only concerned with cross-site browser stuff here.
|
# We are only concerned with cross-site browser stuff here.
|
||||||
if not host:
|
if not host:
|
||||||
self.log.warn("Blocking API request with no host")
|
self.log.warning("Blocking API request with no host")
|
||||||
return False
|
return False
|
||||||
if not referer:
|
if not referer:
|
||||||
self.log.warn("Blocking API request with no referer")
|
self.log.warning("Blocking API request with no referer")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
host_path = url_path_join(host, self.hub.server.base_url)
|
host_path = url_path_join(host, self.hub.server.base_url)
|
||||||
referer_path = referer.split('://', 1)[-1]
|
referer_path = referer.split('://', 1)[-1]
|
||||||
if not (referer_path + '/').startswith(host_path):
|
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)
|
referer, host_path)
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_current_user_cookie(self):
|
def get_current_user_cookie(self):
|
||||||
"""Override get_user_cookie to check Referer header"""
|
"""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 None
|
||||||
return super().get_current_user_cookie()
|
return cookie_user
|
||||||
|
|
||||||
def get_json_body(self):
|
def get_json_body(self):
|
||||||
"""Return the body of the request as JSON data."""
|
"""Return the body of the request as JSON data."""
|
||||||
@@ -83,10 +87,12 @@ class APIHandler(BaseHandler):
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
def user_model(self, user):
|
def user_model(self, user):
|
||||||
|
"""Get the JSON model for a User object"""
|
||||||
model = {
|
model = {
|
||||||
'name': user.name,
|
'name': user.name,
|
||||||
'admin': user.admin,
|
'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,
|
'pending': None,
|
||||||
'last_activity': user.last_activity.isoformat(),
|
'last_activity': user.last_activity.isoformat(),
|
||||||
}
|
}
|
||||||
@@ -96,23 +102,56 @@ class APIHandler(BaseHandler):
|
|||||||
model['pending'] = 'stop'
|
model['pending'] = 'stop'
|
||||||
return model
|
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,
|
'name': str,
|
||||||
'admin': bool,
|
'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):
|
if not isinstance(model, dict):
|
||||||
raise web.HTTPError(400, "Invalid JSON data: %r" % model)
|
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)
|
raise web.HTTPError(400, "Invalid JSON keys: %r" % model)
|
||||||
for key, value in model.items():
|
for key, value in model.items():
|
||||||
if not isinstance(value, self._model_types[key]):
|
if not isinstance(value, model_types[key]):
|
||||||
raise web.HTTPError(400, "user.%s must be %s, not: %r" % (
|
raise web.HTTPError(400, "%s.%s must be %s, not: %r" % (
|
||||||
key, self._model_types[key], type(value)
|
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):
|
def options(self, *args, **kwargs):
|
||||||
self.set_header('Access-Control-Allow-Headers', 'accept, content-type')
|
self.set_header('Access-Control-Allow-Headers', 'accept, content-type')
|
||||||
self.finish()
|
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.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
from tornado import web
|
from tornado import web
|
||||||
from tornado.ioloop import IOLoop
|
from tornado.ioloop import IOLoop
|
||||||
|
|
||||||
from ..utils import admin_only
|
from ..utils import admin_only
|
||||||
from .base import APIHandler
|
from .base import APIHandler
|
||||||
|
from ..version import __version__
|
||||||
|
|
||||||
|
|
||||||
class ShutdownAPIHandler(APIHandler):
|
class ShutdownAPIHandler(APIHandler):
|
||||||
|
|
||||||
@@ -49,6 +52,56 @@ class ShutdownAPIHandler(APIHandler):
|
|||||||
loop.add_callback(loop.stop)
|
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 = [
|
default_handlers = [
|
||||||
(r"/api/shutdown", ShutdownAPIHandler),
|
(r"/api/shutdown", ShutdownAPIHandler),
|
||||||
|
(r"/api/?", RootAPIHandler),
|
||||||
|
(r"/api/info", InfoAPIHandler),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class ProxyAPIHandler(APIHandler):
|
|||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def post(self):
|
def post(self):
|
||||||
"""POST checks the proxy to ensure"""
|
"""POST checks the proxy to ensure"""
|
||||||
yield self.proxy.check_routes()
|
yield self.proxy.check_routes(self.users, self.services)
|
||||||
|
|
||||||
|
|
||||||
@admin_only
|
@admin_only
|
||||||
@@ -59,7 +59,7 @@ class ProxyAPIHandler(APIHandler):
|
|||||||
self.proxy.auth_token = model['auth_token']
|
self.proxy.auth_token = model['auth_token']
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.log.info("Updated proxy at %s", server.bind_url)
|
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
|
continue
|
||||||
user = self.find_user(name)
|
user = self.find_user(name)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
self.log.warn("User %s already exists" % name)
|
self.log.warning("User %s already exists" % name)
|
||||||
else:
|
else:
|
||||||
to_create.append(name)
|
to_create.append(name)
|
||||||
|
|
||||||
@@ -161,8 +161,9 @@ class UserServerAPIHandler(APIHandler):
|
|||||||
@admin_or_self
|
@admin_or_self
|
||||||
def post(self, name):
|
def post(self, name):
|
||||||
user = self.find_user(name)
|
user = self.find_user(name)
|
||||||
if user.spawner:
|
if user.running:
|
||||||
state = yield user.spawner.poll()
|
# include notify, so that a server that died is noticed immediately
|
||||||
|
state = yield user.spawner.poll_and_notify()
|
||||||
if state is None:
|
if state is None:
|
||||||
raise web.HTTPError(400, "%s's server is already running" % name)
|
raise web.HTTPError(400, "%s's server is already running" % name)
|
||||||
|
|
||||||
@@ -180,7 +181,8 @@ class UserServerAPIHandler(APIHandler):
|
|||||||
return
|
return
|
||||||
if not user.running:
|
if not user.running:
|
||||||
raise web.HTTPError(400, "%s's server is not running" % name)
|
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:
|
if status is not None:
|
||||||
raise web.HTTPError(400, "%s's server is not running" % name)
|
raise web.HTTPError(400, "%s's server is not running" % name)
|
||||||
yield self.stop_single_user(user)
|
yield self.stop_single_user(user)
|
||||||
@@ -195,7 +197,7 @@ class UserAdminAccessAPIHandler(APIHandler):
|
|||||||
@admin_only
|
@admin_only
|
||||||
def post(self, name):
|
def post(self, name):
|
||||||
current = self.get_current_user()
|
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,
|
current.name, name,
|
||||||
)
|
)
|
||||||
if not self.settings.get('admin_access', False):
|
if not self.settings.get('admin_access', False):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -15,61 +15,102 @@ from tornado import gen
|
|||||||
import pamela
|
import pamela
|
||||||
|
|
||||||
from traitlets.config import LoggingConfigurable
|
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 .handlers.login import LoginHandler
|
||||||
from .utils import url_path_join
|
from .utils import url_path_join
|
||||||
from .traitlets import Command
|
from .traitlets import Command
|
||||||
|
|
||||||
class Authenticator(LoggingConfigurable):
|
|
||||||
"""A class for authentication.
|
|
||||||
|
|
||||||
The primary API is one method, `authenticate`, a tornado coroutine
|
class Authenticator(LoggingConfigurable):
|
||||||
for authenticating users.
|
"""Base class for implementing an authentication provider for JupyterHub"""
|
||||||
"""
|
|
||||||
|
|
||||||
db = Any()
|
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.
|
admin_users = Set(
|
||||||
"""
|
help="""
|
||||||
)
|
Set of users that will have admin rights on this JupyterHub.
|
||||||
whitelist = Set(config=True,
|
|
||||||
help="""Username whitelist.
|
|
||||||
|
|
||||||
Use this to restrict which users can login.
|
Admin users have extra privilages:
|
||||||
If empty, allow any user to attempt login.
|
- 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)
|
||||||
custom_html = Unicode('',
|
|
||||||
help="""HTML login form for custom handlers.
|
whitelist = Set(
|
||||||
Override in form-based custom authenticators
|
help="""
|
||||||
that don't use username+password,
|
Whitelist of usernames that are allowed to log in.
|
||||||
or need custom branding.
|
|
||||||
|
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)
|
||||||
login_service = Unicode('',
|
|
||||||
help="""Name of the login service for external
|
@observe('whitelist')
|
||||||
login services (e.g. 'GitHub').
|
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.
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
username_pattern = Unicode(config=True,
|
login_service = Unicode(
|
||||||
help="""Regular expression pattern for validating usernames.
|
help="""
|
||||||
|
Name of the login service that this authenticator is providing using to authenticate users.
|
||||||
|
|
||||||
If not defined: allow any username.
|
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.
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
def _username_pattern_changed(self, name, old, new):
|
|
||||||
if not new:
|
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.
|
||||||
|
"""
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
|
@observe('username_pattern')
|
||||||
|
def _username_pattern_changed(self, change):
|
||||||
|
if not change['new']:
|
||||||
self.username_regex = None
|
self.username_regex = None
|
||||||
self.username_regex = re.compile(new)
|
self.username_regex = re.compile(change['new'])
|
||||||
|
|
||||||
username_regex = Any()
|
username_regex = Any(
|
||||||
|
help="""
|
||||||
|
Compiled regex kept in sync with `username_pattern`
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
def validate_username(self, username):
|
def validate_username(self, username):
|
||||||
"""Validate a (normalized) username.
|
"""Validate a normalized username
|
||||||
|
|
||||||
Return True if username is valid, False otherwise.
|
Return True if username is valid, False otherwise.
|
||||||
"""
|
"""
|
||||||
@@ -77,30 +118,30 @@ class Authenticator(LoggingConfigurable):
|
|||||||
return True
|
return True
|
||||||
return bool(self.username_regex.match(username))
|
return bool(self.username_regex.match(username))
|
||||||
|
|
||||||
username_map = Dict(config=True,
|
username_map = Dict(
|
||||||
help="""Dictionary mapping authenticator usernames to JupyterHub users.
|
help="""Dictionary mapping authenticator usernames to JupyterHub users.
|
||||||
|
|
||||||
Can be used to map OAuth service names to local users, for instance.
|
Primarily used to normalize OAuth user names to local users.
|
||||||
|
|
||||||
Used in normalize_username.
|
|
||||||
"""
|
"""
|
||||||
)
|
).tag(config=True)
|
||||||
|
|
||||||
def normalize_username(self, username):
|
def normalize_username(self, username):
|
||||||
"""Normalize a username.
|
"""Normalize the given username and return it
|
||||||
|
|
||||||
Override in subclasses if usernames should have some normalization.
|
Override in subclasses if usernames need different normalization rules.
|
||||||
Default: cast to lowercase, lookup in username_map.
|
|
||||||
|
The default attempts to lowercase the username and apply `username_map` if it is
|
||||||
|
set.
|
||||||
"""
|
"""
|
||||||
username = username.lower()
|
username = username.lower()
|
||||||
username = self.username_map.get(username, username)
|
username = self.username_map.get(username, username)
|
||||||
return username
|
return username
|
||||||
|
|
||||||
def check_whitelist(self, 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.
|
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.
|
Names are normalized *before* being checked against the whitelist.
|
||||||
"""
|
"""
|
||||||
@@ -111,18 +152,21 @@ class Authenticator(LoggingConfigurable):
|
|||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def get_authenticated_user(self, handler, data):
|
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,
|
This calls `authenticate`, which should be overridden in subclasses,
|
||||||
normalizes the username if any normalization should be done,
|
normalizes the username if any normalization should be done,
|
||||||
and then validates the name in the whitelist.
|
and then validates the name in the whitelist.
|
||||||
|
|
||||||
|
This is the outer API for authenticating a user.
|
||||||
Subclasses should not need to override this method.
|
Subclasses should not need to override this method.
|
||||||
The various stages can be overridden separately:
|
|
||||||
|
|
||||||
- authenticate turns formdata into a username
|
The various stages can be overridden separately:
|
||||||
- normalize_username normalizes the username
|
- `authenticate` turns formdata into a username
|
||||||
- check_whitelist checks against the user whitelist
|
- `normalize_username` normalizes the username
|
||||||
|
- `check_whitelist` checks against the user whitelist
|
||||||
"""
|
"""
|
||||||
username = yield self.authenticate(handler, data)
|
username = yield self.authenticate(handler, data)
|
||||||
if username is None:
|
if username is None:
|
||||||
@@ -131,7 +175,9 @@ class Authenticator(LoggingConfigurable):
|
|||||||
if not self.validate_username(username):
|
if not self.validate_username(username):
|
||||||
self.log.warning("Disallowing invalid username %r.", username)
|
self.log.warning("Disallowing invalid username %r.", username)
|
||||||
return
|
return
|
||||||
if self.check_whitelist(username):
|
|
||||||
|
whitelist_pass = yield gen.maybe_future(self.check_whitelist(username))
|
||||||
|
if whitelist_pass:
|
||||||
return username
|
return username
|
||||||
else:
|
else:
|
||||||
self.log.warning("User %r not in whitelist.", username)
|
self.log.warning("User %r not in whitelist.", username)
|
||||||
@@ -139,7 +185,7 @@ class Authenticator(LoggingConfigurable):
|
|||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def authenticate(self, handler, data):
|
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.
|
This must be a tornado gen.coroutine.
|
||||||
It must return the username on successful authentication,
|
It must return the username on successful authentication,
|
||||||
@@ -151,32 +197,40 @@ class Authenticator(LoggingConfigurable):
|
|||||||
handler (tornado.web.RequestHandler): the current request handler
|
handler (tornado.web.RequestHandler): the current request handler
|
||||||
data (dict): The formdata of the login form.
|
data (dict): The formdata of the login form.
|
||||||
The default form has 'username' and 'password' fields.
|
The default form has 'username' and 'password' fields.
|
||||||
Return:
|
Returns:
|
||||||
str: the username of the authenticated user
|
username (str or None): The username of the authenticated user,
|
||||||
None: Authentication failed
|
or None if Authentication failed
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def pre_spawn_start(self, user, spawner):
|
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.
|
Can be used to do auth-related startup, e.g. opening PAM sessions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def post_spawn_stop(self, user, spawner):
|
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.
|
Can be used to do auth-related cleanup, e.g. closing PAM sessions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def add_user(self, user):
|
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.
|
By default, this just adds the user to the whitelist.
|
||||||
|
|
||||||
Subclasses may do more extensive things,
|
Subclasses may do more extensive things, such as adding actual unix users,
|
||||||
such as adding actual unix users,
|
|
||||||
but they should call super to ensure the whitelist is updated.
|
but they should call super to ensure the whitelist is updated.
|
||||||
|
|
||||||
|
Note that this should be idempotent, since it is called whenever the hub restarts
|
||||||
|
for all users.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user (User): The User wrapper object
|
user (User): The User wrapper object
|
||||||
"""
|
"""
|
||||||
@@ -186,7 +240,7 @@ class Authenticator(LoggingConfigurable):
|
|||||||
self.whitelist.add(user.name)
|
self.whitelist.add(user.name)
|
||||||
|
|
||||||
def delete_user(self, user):
|
def delete_user(self, user):
|
||||||
"""Triggered when a user is deleted.
|
"""Hook called when a user is deleted
|
||||||
|
|
||||||
Removes the user from the whitelist.
|
Removes the user from the whitelist.
|
||||||
Subclasses should call super to ensure the whitelist is updated.
|
Subclasses should call super to ensure the whitelist is updated.
|
||||||
@@ -197,23 +251,28 @@ class Authenticator(LoggingConfigurable):
|
|||||||
self.whitelist.discard(user.name)
|
self.whitelist.discard(user.name)
|
||||||
|
|
||||||
def login_url(self, base_url):
|
def login_url(self, base_url):
|
||||||
"""Override to register a custom login handler
|
"""Override this when registering a custom login handler
|
||||||
|
|
||||||
Generally used in combination with get_handlers.
|
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:
|
Args:
|
||||||
base_url (str): the base URL of the Hub (e.g. /hub/)
|
base_url (str): the base URL of the Hub (e.g. /hub/)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: The login URL, e.g. '/hub/login'
|
str: The login URL, e.g. '/hub/login'
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return url_path_join(base_url, 'login')
|
return url_path_join(base_url, 'login')
|
||||||
|
|
||||||
def logout_url(self, base_url):
|
def logout_url(self, base_url):
|
||||||
"""Override to register a custom logout handler.
|
"""Override when registering a custom logout handler
|
||||||
|
|
||||||
Generally used in combination with get_handlers.
|
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:
|
Args:
|
||||||
base_url (str): the base URL of the Hub (e.g. /hub/)
|
base_url (str): the base URL of the Hub (e.g. /hub/)
|
||||||
@@ -226,33 +285,38 @@ class Authenticator(LoggingConfigurable):
|
|||||||
def get_handlers(self, app):
|
def get_handlers(self, app):
|
||||||
"""Return any custom handlers the authenticator needs to register
|
"""Return any custom handlers the authenticator needs to register
|
||||||
|
|
||||||
(e.g. for OAuth).
|
Used in conjugation with `login_url` and `logout_url`.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
app (JupyterHub Application):
|
app (JupyterHub Application):
|
||||||
the application object, in case it needs to be accessed for info.
|
the application object, in case it needs to be accessed for info.
|
||||||
Returns:
|
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.
|
The Hub prefix is added to any URLs.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return [
|
return [
|
||||||
('/login', LoginHandler),
|
('/login', LoginHandler),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class LocalAuthenticator(Authenticator):
|
class LocalAuthenticator(Authenticator):
|
||||||
"""Base class for Authenticators that work with local Linux/UNIX users
|
"""Base class for Authenticators that work with local Linux/UNIX users
|
||||||
|
|
||||||
Checks for local users, and can attempt to create them if they exist.
|
Checks for local users, and can attempt to create them if they exist.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
create_system_users = Bool(False, config=True,
|
create_system_users = Bool(False,
|
||||||
help="""If a user is added that doesn't exist on the system,
|
help="""
|
||||||
should I try to create the system user?
|
If set to True, will attempt to create local system users if they do not exist already.
|
||||||
|
|
||||||
|
Supports Linux and BSD variants only.
|
||||||
"""
|
"""
|
||||||
)
|
).tag(config=True)
|
||||||
add_user_cmd = Command(config=True,
|
|
||||||
help="""The command to use for creating users as a list of strings.
|
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
|
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.
|
the user's username. The username will also be appended as the final argument.
|
||||||
@@ -267,12 +331,15 @@ class LocalAuthenticator(Authenticator):
|
|||||||
|
|
||||||
This will run the command:
|
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.
|
when the user 'river' is created.
|
||||||
"""
|
"""
|
||||||
)
|
).tag(config=True)
|
||||||
|
|
||||||
|
@default('add_user_cmd')
|
||||||
def _add_user_cmd_default(self):
|
def _add_user_cmd_default(self):
|
||||||
|
"""Guess the most likely-to-work adduser command for each platform"""
|
||||||
if sys.platform == 'darwin':
|
if sys.platform == 'darwin':
|
||||||
raise ValueError("I don't know how to create users on OS X")
|
raise ValueError("I don't know how to create users on OS X")
|
||||||
elif which('pw'):
|
elif which('pw'):
|
||||||
@@ -283,13 +350,20 @@ class LocalAuthenticator(Authenticator):
|
|||||||
return ['adduser', '-q', '--gecos', '""', '--disabled-password']
|
return ['adduser', '-q', '--gecos', '""', '--disabled-password']
|
||||||
|
|
||||||
group_whitelist = Set(
|
group_whitelist = Set(
|
||||||
config=True,
|
help="""
|
||||||
help="Automatically whitelist anyone in this group.",
|
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:
|
if self.whitelist:
|
||||||
self.log.warn(
|
self.log.warning(
|
||||||
"Ignoring username whitelist because group whitelist supplied!"
|
"Ignoring username whitelist because group whitelist supplied!"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -300,6 +374,9 @@ class LocalAuthenticator(Authenticator):
|
|||||||
return super().check_whitelist(username)
|
return super().check_whitelist(username)
|
||||||
|
|
||||||
def check_group_whitelist(self, 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:
|
if not self.group_whitelist:
|
||||||
return False
|
return False
|
||||||
for grnam in self.group_whitelist:
|
for grnam in self.group_whitelist:
|
||||||
@@ -314,9 +391,9 @@ class LocalAuthenticator(Authenticator):
|
|||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def add_user(self, user):
|
def add_user(self, user):
|
||||||
"""Add a new user
|
"""Hook called whenever a new user is added
|
||||||
|
|
||||||
If self.create_system_users, the user will attempt to be created.
|
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))
|
user_exists = yield gen.maybe_future(self.system_user_exists(user))
|
||||||
if not user_exists:
|
if not user_exists:
|
||||||
@@ -338,7 +415,10 @@ class LocalAuthenticator(Authenticator):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def add_system_user(self, user):
|
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
|
name = user.name
|
||||||
cmd = [ arg.replace('USERNAME', name) for arg in self.add_user_cmd ] + [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)))
|
self.log.info("Creating user: %s", ' '.join(map(pipes.quote, cmd)))
|
||||||
@@ -350,13 +430,32 @@ class LocalAuthenticator(Authenticator):
|
|||||||
|
|
||||||
|
|
||||||
class PAMAuthenticator(LocalAuthenticator):
|
class PAMAuthenticator(LocalAuthenticator):
|
||||||
"""Authenticate local Linux/UNIX users with PAM"""
|
"""Authenticate local UNIX users with PAM"""
|
||||||
encoding = Unicode('utf8', config=True,
|
|
||||||
help="""The encoding to use for PAM"""
|
encoding = Unicode('utf8',
|
||||||
)
|
help="""
|
||||||
service = Unicode('login', config=True,
|
The text encoding to use when communicating with PAM
|
||||||
help="""The PAM service to use for authentication."""
|
"""
|
||||||
)
|
).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
|
@gen.coroutine
|
||||||
def authenticate(self, handler, data):
|
def authenticate(self, handler, data):
|
||||||
@@ -369,23 +468,30 @@ class PAMAuthenticator(LocalAuthenticator):
|
|||||||
pamela.authenticate(username, data['password'], service=self.service)
|
pamela.authenticate(username, data['password'], service=self.service)
|
||||||
except pamela.PAMError as e:
|
except pamela.PAMError as e:
|
||||||
if handler is not None:
|
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:
|
else:
|
||||||
self.log.warn("PAM Authentication failed: %s", e)
|
self.log.warning("PAM Authentication failed: %s", e)
|
||||||
else:
|
else:
|
||||||
return username
|
return username
|
||||||
|
|
||||||
def pre_spawn_start(self, user, spawner):
|
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:
|
try:
|
||||||
pamela.open_session(user.name, service=self.service)
|
pamela.open_session(user.name, service=self.service)
|
||||||
except pamela.PAMError as e:
|
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):
|
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:
|
try:
|
||||||
pamela.close_session(user.name, service=self.service)
|
pamela.close_session(user.name, service=self.service)
|
||||||
except pamela.PAMError as e:
|
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
|
import re
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from http.client import responses
|
from http.client import responses
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from jinja2 import TemplateNotFound
|
from jinja2 import TemplateNotFound
|
||||||
|
|
||||||
@@ -51,6 +52,14 @@ class BaseHandler(RequestHandler):
|
|||||||
def version_hash(self):
|
def version_hash(self):
|
||||||
return self.settings.get('version_hash', '')
|
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
|
@property
|
||||||
def db(self):
|
def db(self):
|
||||||
return self.settings['db']
|
return self.settings['db']
|
||||||
@@ -60,6 +69,9 @@ class BaseHandler(RequestHandler):
|
|||||||
return self.settings.setdefault('users', {})
|
return self.settings.setdefault('users', {})
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
def services(self):
|
||||||
|
return self.settings.setdefault('services', {})
|
||||||
|
@property
|
||||||
def hub(self):
|
def hub(self):
|
||||||
return self.settings['hub']
|
return self.settings['hub']
|
||||||
|
|
||||||
@@ -67,6 +79,10 @@ class BaseHandler(RequestHandler):
|
|||||||
def proxy(self):
|
def proxy(self):
|
||||||
return self.settings['proxy']
|
return self.settings['proxy']
|
||||||
|
|
||||||
|
@property
|
||||||
|
def statsd(self):
|
||||||
|
return self.settings['statsd']
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def authenticator(self):
|
def authenticator(self):
|
||||||
return self.settings.get('authenticator', None)
|
return self.settings.get('authenticator', None)
|
||||||
@@ -132,7 +148,7 @@ class BaseHandler(RequestHandler):
|
|||||||
if orm_token is None:
|
if orm_token is None:
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
return orm_token.user
|
return orm_token.user or orm_token.service
|
||||||
|
|
||||||
def _user_for_cookie(self, cookie_name, cookie_value=None):
|
def _user_for_cookie(self, cookie_name, cookie_value=None):
|
||||||
"""Get the User for a given cookie, if there is one"""
|
"""Get the User for a given cookie, if there is one"""
|
||||||
@@ -146,14 +162,14 @@ class BaseHandler(RequestHandler):
|
|||||||
|
|
||||||
if cookie_id is None:
|
if cookie_id is None:
|
||||||
if self.get_cookie(cookie_name):
|
if self.get_cookie(cookie_name):
|
||||||
self.log.warn("Invalid or expired cookie token")
|
self.log.warning("Invalid or expired cookie token")
|
||||||
clear()
|
clear()
|
||||||
return
|
return
|
||||||
cookie_id = cookie_id.decode('utf8', 'replace')
|
cookie_id = cookie_id.decode('utf8', 'replace')
|
||||||
u = self.db.query(orm.User).filter(orm.User.cookie_id==cookie_id).first()
|
u = self.db.query(orm.User).filter(orm.User.cookie_id==cookie_id).first()
|
||||||
user = self._user_from_orm(u)
|
user = self._user_from_orm(u)
|
||||||
if user is None:
|
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.
|
# have cookie, but it's not valid. Clear it and start over.
|
||||||
clear()
|
clear()
|
||||||
return user
|
return user
|
||||||
@@ -192,6 +208,7 @@ class BaseHandler(RequestHandler):
|
|||||||
self.db.add(u)
|
self.db.add(u)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
user = self._user_from_orm(u)
|
user = self._user_from_orm(u)
|
||||||
|
self.authenticator.add_user(user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def clear_login_cookie(self, name=None):
|
def clear_login_cookie(self, name=None):
|
||||||
@@ -199,46 +216,60 @@ class BaseHandler(RequestHandler):
|
|||||||
user = self.get_current_user()
|
user = self.get_current_user()
|
||||||
else:
|
else:
|
||||||
user = self.find_user(name)
|
user = self.find_user(name)
|
||||||
|
kwargs = {}
|
||||||
|
if self.subdomain_host:
|
||||||
|
kwargs['domain'] = self.domain
|
||||||
if user and user.server:
|
if user and user.server:
|
||||||
self.clear_cookie(user.server.cookie_name, path=user.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)
|
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):
|
def set_server_cookie(self, user):
|
||||||
"""set the login cookie for the single-user server"""
|
"""set the login cookie for the single-user server"""
|
||||||
# tornado <4.2 have a bug that consider secure==True as soon as
|
self._set_user_cookie(user, user.server)
|
||||||
# '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
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_hub_cookie(self, user):
|
def set_hub_cookie(self, user):
|
||||||
"""set the login cookie for the Hub"""
|
"""set the login cookie for the Hub"""
|
||||||
# tornado <4.2 have a bug that consider secure==True as soon as
|
self._set_user_cookie(user, self.hub.server)
|
||||||
# '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
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_login_cookie(self, user):
|
def set_login_cookie(self, user):
|
||||||
"""Set login cookies for the Hub and single-user server."""
|
"""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
|
# create and set a new cookie token for the single-user server
|
||||||
if user.server:
|
if user.server:
|
||||||
self.set_server_cookie(user)
|
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
|
# create and set a new cookie token for the hub
|
||||||
if not self.get_current_user_cookie():
|
if not self.get_current_user_cookie():
|
||||||
self.set_hub_cookie(user)
|
self.set_hub_cookie(user)
|
||||||
@@ -289,23 +320,38 @@ class BaseHandler(RequestHandler):
|
|||||||
return
|
return
|
||||||
toc = IOLoop.current().time()
|
toc = IOLoop.current().time()
|
||||||
self.log.info("User %s server took %.3f seconds to start", user.name, toc-tic)
|
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)
|
yield self.proxy.add_user(user)
|
||||||
user.spawner.add_poll_callback(self.user_stopped, user)
|
user.spawner.add_poll_callback(self.user_stopped, user)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yield gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), f)
|
yield gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), f)
|
||||||
except gen.TimeoutError:
|
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()
|
status = yield user.spawner.poll()
|
||||||
if status is None:
|
if status is None:
|
||||||
# hit timeout, but spawn is still pending
|
# hit timeout, but server's running. Hope that it'll show up soon enough,
|
||||||
self.log.warn("User %s server is slow to start", user.name)
|
# 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
|
# schedule finish for when the user finishes spawning
|
||||||
IOLoop.current().add_future(f, finish_user_spawn)
|
IOLoop.current().add_future(f, finish_user_spawn)
|
||||||
else:
|
else:
|
||||||
|
toc = IOLoop.current().time()
|
||||||
|
self.statsd.timing('spawner.failure', (toc - tic) * 1000)
|
||||||
raise web.HTTPError(500, "Spawner failed to start [status=%s]" % status)
|
raise web.HTTPError(500, "Spawner failed to start [status=%s]" % status)
|
||||||
else:
|
|
||||||
raise
|
|
||||||
else:
|
else:
|
||||||
yield finish_user_spawn()
|
yield finish_user_spawn()
|
||||||
|
|
||||||
@@ -315,7 +361,7 @@ class BaseHandler(RequestHandler):
|
|||||||
status = yield user.spawner.poll()
|
status = yield user.spawner.poll()
|
||||||
if status is None:
|
if status is None:
|
||||||
status = 'unknown'
|
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,
|
user.name, status,
|
||||||
)
|
)
|
||||||
yield self.proxy.delete_user(user)
|
yield self.proxy.delete_user(user)
|
||||||
@@ -346,7 +392,7 @@ class BaseHandler(RequestHandler):
|
|||||||
except gen.TimeoutError:
|
except gen.TimeoutError:
|
||||||
if user.stop_pending:
|
if user.stop_pending:
|
||||||
# hit timeout, but stop is still 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
|
# schedule finish for when the server finishes stopping
|
||||||
IOLoop.current().add_future(f, finish_stop)
|
IOLoop.current().add_future(f, finish_stop)
|
||||||
else:
|
else:
|
||||||
@@ -385,6 +431,7 @@ class BaseHandler(RequestHandler):
|
|||||||
"""render custom error pages"""
|
"""render custom error pages"""
|
||||||
exc_info = kwargs.get('exc_info')
|
exc_info = kwargs.get('exc_info')
|
||||||
message = ''
|
message = ''
|
||||||
|
exception = None
|
||||||
status_message = responses.get(status_code, 'Unknown HTTP Error')
|
status_message = responses.get(status_code, 'Unknown HTTP Error')
|
||||||
if exc_info:
|
if exc_info:
|
||||||
exception = exc_info[1]
|
exception = exc_info[1]
|
||||||
@@ -430,25 +477,50 @@ class PrefixRedirectHandler(BaseHandler):
|
|||||||
Redirects /foo to /prefix/foo, etc.
|
Redirects /foo to /prefix/foo, etc.
|
||||||
"""
|
"""
|
||||||
def get(self):
|
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.redirect(url_path_join(
|
||||||
self.hub.server.base_url, path,
|
self.hub.server.base_url, path,
|
||||||
), permanent=False)
|
), permanent=False)
|
||||||
|
|
||||||
|
|
||||||
class UserSpawnHandler(BaseHandler):
|
class UserSpawnHandler(BaseHandler):
|
||||||
"""Requests to /user/name handled by the Hub
|
"""Redirect requests to /user/name/* handled by the Hub.
|
||||||
should result in spawning the single-user server and
|
|
||||||
being redirected to the original.
|
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
|
@gen.coroutine
|
||||||
def get(self, name):
|
def get(self, name, user_path):
|
||||||
current_user = self.get_current_user()
|
current_user = self.get_current_user()
|
||||||
if current_user and current_user.name == name:
|
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.spawner:
|
||||||
if current_user.spawn_pending:
|
if current_user.spawn_pending:
|
||||||
# spawn has started, but not finished
|
# spawn has started, but not finished
|
||||||
|
self.statsd.incr('redirects.user_spawn_pending', 1)
|
||||||
html = self.render_template("spawn_pending.html", user=current_user)
|
html = self.render_template("spawn_pending.html", user=current_user)
|
||||||
self.finish(html)
|
self.finish(html)
|
||||||
return
|
return
|
||||||
@@ -465,25 +537,61 @@ class UserSpawnHandler(BaseHandler):
|
|||||||
self.set_login_cookie(current_user)
|
self.set_login_cookie(current_user)
|
||||||
without_prefix = self.request.uri[len(self.hub.server.base_url):]
|
without_prefix = self.request.uri[len(self.hub.server.base_url):]
|
||||||
target = url_path_join(self.base_url, without_prefix)
|
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)
|
self.redirect(target)
|
||||||
else:
|
else:
|
||||||
# not logged in to the right user,
|
# not logged in, clear any cookies and reload
|
||||||
# clear any cookies and reload (will redirect to login)
|
self.statsd.incr('redirects.user_to_login', 1)
|
||||||
self.clear_login_cookie()
|
self.clear_login_cookie()
|
||||||
self.redirect(url_concat(
|
self.redirect(url_concat(
|
||||||
self.settings['login_url'],
|
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)
|
||||||
|
if self.request.query:
|
||||||
|
# FIXME: use urlunparse instead?
|
||||||
|
url += '?' + self.request.query
|
||||||
|
self.redirect(url)
|
||||||
|
|
||||||
|
|
||||||
class CSPReportHandler(BaseHandler):
|
class CSPReportHandler(BaseHandler):
|
||||||
'''Accepts a content security policy violation report'''
|
'''Accepts a content security policy violation report'''
|
||||||
@web.authenticated
|
@web.authenticated
|
||||||
def post(self):
|
def post(self):
|
||||||
'''Log a content security policy violation report'''
|
'''Log a content security policy violation report'''
|
||||||
self.log.warn("Content security violation: %s",
|
self.log.warning(
|
||||||
self.request.body.decode('utf8', 'replace'))
|
"Content security violation: %s",
|
||||||
|
self.request.body.decode('utf8', 'replace')
|
||||||
|
)
|
||||||
|
# Report it to statsd as well
|
||||||
|
self.statsd.incr('csp_report')
|
||||||
|
|
||||||
|
|
||||||
default_handlers = [
|
default_handlers = [
|
||||||
(r'/user/([^/]+)/?.*', UserSpawnHandler),
|
(r'/user/([^/]+)(/.*)?', UserSpawnHandler),
|
||||||
|
(r'/user-redirect/(.*)?', UserRedirectHandler),
|
||||||
(r'/security/csp-report', CSPReportHandler),
|
(r'/security/csp-report', CSPReportHandler),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from tornado.escape import url_escape
|
from tornado.escape import url_escape
|
||||||
from tornado import gen
|
from tornado import gen
|
||||||
|
|
||||||
@@ -15,10 +17,11 @@ class LogoutHandler(BaseHandler):
|
|||||||
user = self.get_current_user()
|
user = self.get_current_user()
|
||||||
if user:
|
if user:
|
||||||
self.log.info("User logged out: %s", user.name)
|
self.log.info("User logged out: %s", user.name)
|
||||||
self.clear_login_cookie()
|
self.clear_login_cookie()
|
||||||
for name in user.other_user_cookies:
|
for name in user.other_user_cookies:
|
||||||
self.clear_login_cookie(name)
|
self.clear_login_cookie(name)
|
||||||
user.other_user_cookies = set([])
|
user.other_user_cookies = set([])
|
||||||
|
self.statsd.incr('logout')
|
||||||
self.redirect(self.hub.server.base_url, permanent=False)
|
self.redirect(self.hub.server.base_url, permanent=False)
|
||||||
|
|
||||||
|
|
||||||
@@ -35,15 +38,19 @@ class LoginHandler(BaseHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
|
self.statsd.incr('login.request')
|
||||||
next_url = self.get_argument('next', '')
|
next_url = self.get_argument('next', '')
|
||||||
if not next_url.startswith('/'):
|
if (next_url + '/').startswith('%s://%s/' % (self.request.protocol, self.request.host)):
|
||||||
# disallow non-absolute next URLs (e.g. full URLs)
|
# 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 = ''
|
next_url = ''
|
||||||
user = self.get_current_user()
|
user = self.get_current_user()
|
||||||
if user:
|
if user:
|
||||||
if not next_url:
|
if not next_url:
|
||||||
if user.running:
|
if user.running:
|
||||||
next_url = user.server.base_url
|
next_url = user.url
|
||||||
else:
|
else:
|
||||||
next_url = self.hub.server.base_url
|
next_url = self.hub.server.base_url
|
||||||
# set new login cookie
|
# set new login cookie
|
||||||
@@ -61,8 +68,13 @@ class LoginHandler(BaseHandler):
|
|||||||
for arg in self.request.arguments:
|
for arg in self.request.arguments:
|
||||||
data[arg] = self.get_argument(arg)
|
data[arg] = self.get_argument(arg)
|
||||||
|
|
||||||
|
auth_timer = self.statsd.timer('login.authenticate').start()
|
||||||
username = yield self.authenticate(data)
|
username = yield self.authenticate(data)
|
||||||
|
auth_timer.stop(send=False)
|
||||||
|
|
||||||
if username:
|
if username:
|
||||||
|
self.statsd.incr('login.success')
|
||||||
|
self.statsd.timing('login.authenticate.success', auth_timer.ms)
|
||||||
user = self.user_from_username(username)
|
user = self.user_from_username(username)
|
||||||
already_running = False
|
already_running = False
|
||||||
if user.spawner:
|
if user.spawner:
|
||||||
@@ -78,7 +90,9 @@ class LoginHandler(BaseHandler):
|
|||||||
self.redirect(next_url)
|
self.redirect(next_url)
|
||||||
self.log.info("User logged in: %s", username)
|
self.log.info("User logged in: %s", username)
|
||||||
else:
|
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(
|
html = self._render(
|
||||||
login_error='Invalid username or password',
|
login_error='Invalid username or password',
|
||||||
username=username,
|
username=username,
|
||||||
@@ -86,7 +100,10 @@ class LoginHandler(BaseHandler):
|
|||||||
self.finish(html)
|
self.finish(html)
|
||||||
|
|
||||||
|
|
||||||
# Only logout is a default handler.
|
# /login renders the login page or the "Login with..." link,
|
||||||
|
# so it should always be registered.
|
||||||
|
# /logout clears cookies.
|
||||||
default_handlers = [
|
default_handlers = [
|
||||||
|
(r"/login", LoginHandler),
|
||||||
(r"/logout", LogoutHandler),
|
(r"/logout", LogoutHandler),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,17 +3,22 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
|
from http.client import responses
|
||||||
|
|
||||||
|
from jinja2 import TemplateNotFound
|
||||||
from tornado import web, gen
|
from tornado import web, gen
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from ..utils import admin_only, url_path_join
|
from ..utils import admin_only, url_path_join
|
||||||
from .base import BaseHandler
|
from .base import BaseHandler
|
||||||
from .login import LoginHandler
|
|
||||||
|
|
||||||
|
|
||||||
class RootHandler(BaseHandler):
|
class RootHandler(BaseHandler):
|
||||||
"""Render the Hub root page.
|
"""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:
|
If logged in, redirects to:
|
||||||
|
|
||||||
- single-user server if running
|
- single-user server if running
|
||||||
@@ -22,17 +27,32 @@ class RootHandler(BaseHandler):
|
|||||||
Otherwise, renders login page.
|
Otherwise, renders login page.
|
||||||
"""
|
"""
|
||||||
def get(self):
|
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()
|
user = self.get_current_user()
|
||||||
if user:
|
if user:
|
||||||
if user.running:
|
if user.running:
|
||||||
url = user.server.base_url
|
url = user.url
|
||||||
self.log.debug("User is running: %s", url)
|
self.log.debug("User is running: %s", url)
|
||||||
|
self.set_login_cookie(user) # set cookie
|
||||||
else:
|
else:
|
||||||
url = url_path_join(self.hub.server.base_url, 'home')
|
url = url_path_join(self.hub.server.base_url, 'home')
|
||||||
self.log.debug("User is not running: %s", url)
|
self.log.debug("User is not running: %s", url)
|
||||||
self.redirect(url)
|
else:
|
||||||
return
|
url = self.authenticator.login_url(self.base_url)
|
||||||
url = url_path_join(self.hub.server.base_url, 'login')
|
|
||||||
self.redirect(url)
|
self.redirect(url)
|
||||||
|
|
||||||
|
|
||||||
@@ -40,9 +60,14 @@ class HomeHandler(BaseHandler):
|
|||||||
"""Render the user's home page."""
|
"""Render the user's home page."""
|
||||||
|
|
||||||
@web.authenticated
|
@web.authenticated
|
||||||
|
@gen.coroutine
|
||||||
def get(self):
|
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',
|
html = self.render_template('home.html',
|
||||||
user=self.get_current_user(),
|
user=user,
|
||||||
)
|
)
|
||||||
self.finish(html)
|
self.finish(html)
|
||||||
|
|
||||||
@@ -67,7 +92,7 @@ class SpawnHandler(BaseHandler):
|
|||||||
"""GET renders form for spawning with user-specified options"""
|
"""GET renders form for spawning with user-specified options"""
|
||||||
user = self.get_current_user()
|
user = self.get_current_user()
|
||||||
if user.running:
|
if user.running:
|
||||||
url = user.server.base_url
|
url = user.url
|
||||||
self.log.debug("User is running: %s", url)
|
self.log.debug("User is running: %s", url)
|
||||||
self.redirect(url)
|
self.redirect(url)
|
||||||
return
|
return
|
||||||
@@ -75,8 +100,7 @@ class SpawnHandler(BaseHandler):
|
|||||||
self.finish(self._render_form())
|
self.finish(self._render_form())
|
||||||
else:
|
else:
|
||||||
# not running, no form. Trigger spawn.
|
# not running, no form. Trigger spawn.
|
||||||
url = url_path_join(self.base_url, 'user', user.name)
|
self.redirect(user.url)
|
||||||
self.redirect(url)
|
|
||||||
|
|
||||||
@web.authenticated
|
@web.authenticated
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
@@ -84,7 +108,7 @@ class SpawnHandler(BaseHandler):
|
|||||||
"""POST spawns with user-specified options"""
|
"""POST spawns with user-specified options"""
|
||||||
user = self.get_current_user()
|
user = self.get_current_user()
|
||||||
if user.running:
|
if user.running:
|
||||||
url = user.server.base_url
|
url = user.url
|
||||||
self.log.warning("User is already running: %s", url)
|
self.log.warning("User is already running: %s", url)
|
||||||
self.redirect(url)
|
self.redirect(url)
|
||||||
return
|
return
|
||||||
@@ -93,15 +117,15 @@ class SpawnHandler(BaseHandler):
|
|||||||
form_options[key] = [ bs.decode('utf8') for bs in byte_list ]
|
form_options[key] = [ bs.decode('utf8') for bs in byte_list ]
|
||||||
for key, byte_list in self.request.files.items():
|
for key, byte_list in self.request.files.items():
|
||||||
form_options["%s_file"%key] = byte_list
|
form_options["%s_file"%key] = byte_list
|
||||||
options = user.spawner.options_from_form(form_options)
|
|
||||||
try:
|
try:
|
||||||
|
options = user.spawner.options_from_form(form_options)
|
||||||
yield self.spawn_single_user(user, options=options)
|
yield self.spawn_single_user(user, options=options)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.error("Failed to spawn single-user server with form", exc_info=True)
|
self.log.error("Failed to spawn single-user server with form", exc_info=True)
|
||||||
self.finish(self._render_form(str(e)))
|
self.finish(self._render_form(str(e)))
|
||||||
return
|
return
|
||||||
self.set_login_cookie(user)
|
self.set_login_cookie(user)
|
||||||
url = user.server.base_url
|
url = user.url
|
||||||
self.redirect(url)
|
self.redirect(url)
|
||||||
|
|
||||||
class AdminHandler(BaseHandler):
|
class AdminHandler(BaseHandler):
|
||||||
@@ -124,10 +148,10 @@ class AdminHandler(BaseHandler):
|
|||||||
orders = self.get_arguments('order')
|
orders = self.get_arguments('order')
|
||||||
|
|
||||||
for bad in set(sorts).difference(available):
|
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)
|
sorts.remove(bad)
|
||||||
for bad in set(orders).difference({'asc', 'desc'}):
|
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)
|
orders.remove(bad)
|
||||||
|
|
||||||
# add default sort as secondary
|
# add default sort as secondary
|
||||||
@@ -160,9 +184,43 @@ class AdminHandler(BaseHandler):
|
|||||||
self.finish(html)
|
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 = [
|
default_handlers = [
|
||||||
(r'/', RootHandler),
|
(r'/', RootHandler),
|
||||||
(r'/home', HomeHandler),
|
(r'/home', HomeHandler),
|
||||||
(r'/admin', AdminHandler),
|
(r'/admin', AdminHandler),
|
||||||
(r'/spawn', SpawnHandler),
|
(r'/spawn', SpawnHandler),
|
||||||
|
(r'/error/(\d+)', ProxyErrorHandler),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
|
import os
|
||||||
from tornado.web import StaticFileHandler
|
from tornado.web import StaticFileHandler
|
||||||
|
|
||||||
class CacheControlStaticFilesHandler(StaticFileHandler):
|
class CacheControlStaticFilesHandler(StaticFileHandler):
|
||||||
@@ -15,3 +16,13 @@ class CacheControlStaticFilesHandler(StaticFileHandler):
|
|||||||
if "v" not in self.request.arguments:
|
if "v" not in self.request.arguments:
|
||||||
self.add_header("Cache-Control", "no-cache")
|
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.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import errno
|
|
||||||
import json
|
import json
|
||||||
import socket
|
|
||||||
|
|
||||||
from tornado import gen
|
from tornado import gen
|
||||||
from tornado.log import app_log
|
from tornado.log import app_log
|
||||||
from tornado.httpclient import HTTPRequest, AsyncHTTPClient
|
from tornado.httpclient import HTTPRequest, AsyncHTTPClient
|
||||||
|
|
||||||
from sqlalchemy.types import TypeDecorator, VARCHAR
|
from sqlalchemy.types import TypeDecorator, TEXT
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
inspect,
|
inspect,
|
||||||
Column, Integer, ForeignKey, Unicode, Boolean,
|
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.orm import sessionmaker, relationship
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
from sqlalchemy.sql.expression import bindparam
|
from sqlalchemy.sql.expression import bindparam
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, Table
|
||||||
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
random_port, url_path_join, wait_for_server, wait_for_http_server,
|
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):
|
def process_bind_param(self, value, dialect):
|
||||||
if value is not None:
|
if value is not None:
|
||||||
@@ -64,11 +62,11 @@ class Server(Base):
|
|||||||
"""
|
"""
|
||||||
__tablename__ = 'servers'
|
__tablename__ = 'servers'
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
proto = Column(Unicode, default='http')
|
proto = Column(Unicode(15), default='http')
|
||||||
ip = Column(Unicode, default='')
|
ip = Column(Unicode(255), default='') # could also be a DNS name
|
||||||
port = Column(Integer, default=random_port)
|
port = Column(Integer, default=random_port)
|
||||||
base_url = Column(Unicode, default='/')
|
base_url = Column(Unicode(255), default='/')
|
||||||
cookie_name = Column(Unicode, default='cookie')
|
cookie_name = Column(Unicode(255), default='cookie')
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "<Server(%s:%s)>" % (self.ip, self.port)
|
return "<Server(%s:%s)>" % (self.ip, self.port)
|
||||||
@@ -78,7 +76,7 @@ class Server(Base):
|
|||||||
ip = self.ip
|
ip = self.ip
|
||||||
if ip in {'', '0.0.0.0'}:
|
if ip in {'', '0.0.0.0'}:
|
||||||
# when listening on all interfaces, connect to localhost
|
# when listening on all interfaces, connect to localhost
|
||||||
ip = localhost()
|
ip = '127.0.0.1'
|
||||||
return "{proto}://{ip}:{port}".format(
|
return "{proto}://{ip}:{port}".format(
|
||||||
proto=self.proto,
|
proto=self.proto,
|
||||||
ip=ip,
|
ip=ip,
|
||||||
@@ -100,7 +98,7 @@ class Server(Base):
|
|||||||
since it can be non-connectable value, such as '', meaning all interfaces.
|
since it can be non-connectable value, such as '', meaning all interfaces.
|
||||||
"""
|
"""
|
||||||
if self.ip in {'', '0.0.0.0'}:
|
if self.ip in {'', '0.0.0.0'}:
|
||||||
return self.url.replace('localhost', self.ip or '*', 1)
|
return self.url.replace('127.0.0.1', self.ip or '*', 1)
|
||||||
return self.url
|
return self.url
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
@@ -109,29 +107,11 @@ class Server(Base):
|
|||||||
if http:
|
if http:
|
||||||
yield wait_for_http_server(self.url, timeout=timeout)
|
yield wait_for_http_server(self.url, timeout=timeout)
|
||||||
else:
|
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):
|
def is_up(self):
|
||||||
"""Is the server accepting connections?"""
|
"""Is the server accepting connections?"""
|
||||||
try:
|
return can_connect(self.ip or '127.0.0.1', self.port)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class Proxy(Base):
|
class Proxy(Base):
|
||||||
@@ -172,14 +152,47 @@ class Proxy(Base):
|
|||||||
|
|
||||||
return client.fetch(req)
|
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
|
@gen.coroutine
|
||||||
def add_user(self, user, client=None):
|
def add_user(self, user, client=None):
|
||||||
"""Add a user's server to the proxy table."""
|
"""Add a user's server to the proxy table."""
|
||||||
self.log.info("Adding user %s to proxy %s => %s",
|
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',
|
method='POST',
|
||||||
body=dict(
|
body=dict(
|
||||||
target=user.server.host,
|
target=user.server.host,
|
||||||
@@ -190,23 +203,40 @@ class Proxy(Base):
|
|||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def delete_user(self, user, client=None):
|
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)
|
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',
|
method='DELETE',
|
||||||
client=client,
|
client=client,
|
||||||
)
|
)
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def add_all_users(self):
|
def add_all_services(self, service_dict):
|
||||||
"""Update the proxy table from the database.
|
"""Update the proxy table from the database.
|
||||||
|
|
||||||
Used when loading up a new proxy.
|
Used when loading up a new proxy.
|
||||||
"""
|
"""
|
||||||
db = inspect(self).session
|
db = inspect(self).session
|
||||||
futures = []
|
futures = []
|
||||||
for user in db.query(User):
|
for orm_service in db.query(Service):
|
||||||
if (user.server):
|
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))
|
futures.append(self.add_user(user))
|
||||||
# wait after submitting them all
|
# wait after submitting them all
|
||||||
for f in futures:
|
for f in futures:
|
||||||
@@ -219,18 +249,38 @@ class Proxy(Base):
|
|||||||
return json.loads(resp.body.decode('utf8', 'replace'))
|
return json.loads(resp.body.decode('utf8', 'replace'))
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def check_routes(self, routes=None):
|
def check_routes(self, user_dict, service_dict, routes=None):
|
||||||
"""Check that all users are properly"""
|
"""Check that all users are properly routed on the proxy"""
|
||||||
if not routes:
|
if not routes:
|
||||||
routes = yield self.get_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 = []
|
futures = []
|
||||||
db = inspect(self).session
|
db = inspect(self).session
|
||||||
for user in db.query(User).filter(User.server != None):
|
for orm_user in db.query(User):
|
||||||
if user.name not in have_routes:
|
user = user_dict[orm_user]
|
||||||
self.log.warn("Adding missing route for %s", user.name)
|
if user.running:
|
||||||
futures.append(self.add_user(user))
|
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:
|
for f in futures:
|
||||||
yield f
|
yield f
|
||||||
|
|
||||||
@@ -248,6 +298,7 @@ class Hub(Base):
|
|||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
_server_id = Column(Integer, ForeignKey('servers.id'))
|
_server_id = Column(Integer, ForeignKey('servers.id'))
|
||||||
server = relationship(Server, primaryjoin=_server_id == Server.id)
|
server = relationship(Server, primaryjoin=_server_id == Server.id)
|
||||||
|
host = ''
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def api_url(self):
|
def api_url(self):
|
||||||
@@ -263,6 +314,32 @@ class Hub(Base):
|
|||||||
return "<%s [unconfigured]>" % self.__class__.__name__
|
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):
|
class User(Base):
|
||||||
"""The User table
|
"""The User table
|
||||||
|
|
||||||
@@ -281,16 +358,22 @@ class User(Base):
|
|||||||
"""
|
"""
|
||||||
__tablename__ = 'users'
|
__tablename__ = 'users'
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
name = Column(Unicode)
|
name = Column(Unicode(1023), unique=True)
|
||||||
# should we allow multiple servers per user?
|
# 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)
|
server = relationship(Server, primaryjoin=_server_id == Server.id)
|
||||||
admin = Column(Boolean, default=False)
|
admin = Column(Boolean, default=False)
|
||||||
last_activity = Column(DateTime, default=datetime.utcnow)
|
last_activity = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
api_tokens = relationship("APIToken", backref="user")
|
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)
|
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([])
|
other_user_cookies = set([])
|
||||||
|
|
||||||
@@ -308,16 +391,12 @@ class User(Base):
|
|||||||
name=self.name,
|
name=self.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
def new_api_token(self):
|
def new_api_token(self, token=None):
|
||||||
"""Create a new API token"""
|
"""Create a new API token
|
||||||
assert self.id is not None
|
|
||||||
db = inspect(self).session
|
If `token` is given, load that token.
|
||||||
token = new_token()
|
"""
|
||||||
orm_token = APIToken(user_id=self.id)
|
return APIToken.new(token=token, user=self)
|
||||||
orm_token.token = token
|
|
||||||
db.add(orm_token)
|
|
||||||
db.commit()
|
|
||||||
return token
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find(cls, db, name):
|
def find(cls, db, name):
|
||||||
@@ -327,17 +406,71 @@ class User(Base):
|
|||||||
"""
|
"""
|
||||||
return db.query(cls).filter(cls.name==name).first()
|
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):
|
class APIToken(Base):
|
||||||
"""An API token"""
|
"""An API token"""
|
||||||
__tablename__ = 'api_tokens'
|
__tablename__ = 'api_tokens'
|
||||||
|
|
||||||
|
# _constraint = ForeignKeyConstraint(['user_id', 'server_id'], ['users.id', 'services.id'])
|
||||||
@declared_attr
|
@declared_attr
|
||||||
def user_id(cls):
|
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)
|
id = Column(Integer, primary_key=True)
|
||||||
hashed = Column(Unicode)
|
hashed = Column(Unicode(1023))
|
||||||
prefix = Column(Unicode)
|
prefix = Column(Unicode(1023))
|
||||||
prefix_length = 4
|
prefix_length = 4
|
||||||
algorithm = "sha512"
|
algorithm = "sha512"
|
||||||
rounds = 16384
|
rounds = 16384
|
||||||
@@ -354,22 +487,42 @@ class APIToken(Base):
|
|||||||
self.hashed = hash_token(token, rounds=self.rounds, salt=self.salt_bytes, algorithm=self.algorithm)
|
self.hashed = hash_token(token, rounds=self.rounds, salt=self.salt_bytes, algorithm=self.algorithm)
|
||||||
|
|
||||||
def __repr__(self):
|
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__,
|
cls=self.__class__.__name__,
|
||||||
pre=self.prefix,
|
pre=self.prefix,
|
||||||
u=self.user.name,
|
kind=kind,
|
||||||
|
name=name,
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find(cls, db, token):
|
def find(cls, db, token, *, kind=None):
|
||||||
"""Find a token object by value.
|
"""Find a token object by value.
|
||||||
|
|
||||||
Returns None if not found.
|
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]
|
prefix = token[:cls.prefix_length]
|
||||||
# since we can't filter on hashed values, filter on prefix
|
# since we can't filter on hashed values, filter on prefix
|
||||||
# so we aren't comparing with all tokens
|
# so we aren't comparing with all tokens
|
||||||
prefix_match = db.query(cls).filter(bindparam('prefix', prefix).startswith(cls.prefix))
|
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:
|
for orm_token in prefix_match:
|
||||||
if orm_token.match(token):
|
if orm_token.match(token):
|
||||||
return orm_token
|
return orm_token
|
||||||
@@ -378,11 +531,38 @@ class APIToken(Base):
|
|||||||
"""Is this my token?"""
|
"""Is this my token?"""
|
||||||
return compare_token(self.hashed, 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):
|
def new_session_factory(url="sqlite:///:memory:", reset=False, **kwargs):
|
||||||
"""Create a new session at url"""
|
"""Create a new session at url"""
|
||||||
if url.startswith('sqlite'):
|
if url.startswith('sqlite'):
|
||||||
kwargs.setdefault('connect_args', {'check_same_thread': False})
|
kwargs.setdefault('connect_args', {'check_same_thread': False})
|
||||||
|
elif url.startswith('mysql'):
|
||||||
|
kwargs.setdefault('pool_recycle', 60)
|
||||||
|
|
||||||
if url.endswith(':memory:'):
|
if url.endswith(':memory:'):
|
||||||
# If we're using an in-memory database, ensure that only one connection
|
# 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
311
jupyterhub/services/auth.py
Normal file
311
jupyterhub/services/auth.py
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
"""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:
|
||||||
|
app_log.warning("No Hub user identified for request")
|
||||||
|
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()
|
||||||
|
app_log.debug("Received request from Hub user %s", data)
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
name = user_model['name']
|
||||||
|
if self.hub_users is None and self.hub_groups is None:
|
||||||
|
# no whitelist specified, allow any authenticated Hub user
|
||||||
|
app_log.debug("Allowing Hub user %s (all Hub users allowed)", name)
|
||||||
|
return user_model
|
||||||
|
if self.hub_users and name in self.hub_users:
|
||||||
|
# user in whitelist
|
||||||
|
app_log.debug("Allowing whitelisted Hub user %s", name)
|
||||||
|
return user_model
|
||||||
|
elif self.hub_groups and set(user_model['groups']).intersection(self.hub_groups):
|
||||||
|
allowed_groups = set(user_model['groups']).intersection(self.hub_groups)
|
||||||
|
app_log.debug("Allowing Hub user %s in group(s) %s", name, ','.join(sorted(allowed_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.
|
||||||
|
"""
|
||||||
|
if hasattr(self, '_hub_auth_user_cache'):
|
||||||
|
return self._hub_auth_user_cache
|
||||||
|
user_model = self.hub_auth.get_user(self)
|
||||||
|
if not user_model:
|
||||||
|
self._hub_auth_user_cache = None
|
||||||
|
return
|
||||||
|
self._hub_auth_user_cache = self.check_hub_user(user_model)
|
||||||
|
return self._hub_auth_user_cache
|
||||||
|
|
||||||
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()
|
||||||
326
jupyterhub/singleuser.py
Executable file
326
jupyterhub/singleuser.py
Executable file
@@ -0,0 +1,326 @@
|
|||||||
|
#!/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 urllib.parse import urlparse
|
||||||
|
|
||||||
|
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,
|
||||||
|
observe,
|
||||||
|
validate,
|
||||||
|
TraitError,
|
||||||
|
)
|
||||||
|
|
||||||
|
from notebook.notebookapp import (
|
||||||
|
NotebookApp,
|
||||||
|
aliases as notebook_aliases,
|
||||||
|
flags as notebook_flags,
|
||||||
|
)
|
||||||
|
from notebook.auth.login import LoginHandler
|
||||||
|
from notebook.auth.logout import LogoutHandler
|
||||||
|
|
||||||
|
from 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'] }
|
||||||
|
@property
|
||||||
|
def hub_groups(self):
|
||||||
|
if self.settings['group']:
|
||||||
|
return { self.settings['group'] }
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
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',
|
||||||
|
'group': 'SingleUserNotebookApp.group',
|
||||||
|
'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().tag(config=True)
|
||||||
|
group = CUnicode().tag(config=True)
|
||||||
|
@observe('user')
|
||||||
|
def _user_changed(self, change):
|
||||||
|
self.log.name = change.new
|
||||||
|
|
||||||
|
hub_host = Unicode().tag(config=True)
|
||||||
|
|
||||||
|
hub_prefix = Unicode('/hub/').tag(config=True)
|
||||||
|
@default('hub_prefix')
|
||||||
|
def _hub_prefix_default(self):
|
||||||
|
base_url = os.environ.get('JUPYTERHUB_BASE_URL') or '/'
|
||||||
|
return base_url + 'hub/'
|
||||||
|
|
||||||
|
hub_api_url = Unicode().tag(config=True)
|
||||||
|
@default('hub_api_url')
|
||||||
|
def _hub_api_url_default(self):
|
||||||
|
return os.environ.get('JUPYTERHUB_API_URL') or 'http://127.0.0.1:8081/hub/api'
|
||||||
|
|
||||||
|
# defaults for some configurables that may come from service env variables:
|
||||||
|
@default('base_url')
|
||||||
|
def _base_url_default(self):
|
||||||
|
return os.environ.get('JUPYTERHUB_SERVICE_PREFIX') or '/'
|
||||||
|
|
||||||
|
@default('cookie_name')
|
||||||
|
def _cookie_name_default(self):
|
||||||
|
if os.environ.get('JUPYTERHUB_SERVICE_NAME'):
|
||||||
|
# if I'm a service, use the services cookie name
|
||||||
|
return 'jupyterhub-services'
|
||||||
|
|
||||||
|
@default('port')
|
||||||
|
def _port_default(self):
|
||||||
|
if os.environ.get('JUPYTERHUB_SERVICE_URL'):
|
||||||
|
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
|
||||||
|
return url.port
|
||||||
|
|
||||||
|
@default('ip')
|
||||||
|
def _ip_default(self):
|
||||||
|
if os.environ.get('JUPYTERHUB_SERVICE_URL'):
|
||||||
|
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
|
||||||
|
return url.hostname
|
||||||
|
|
||||||
|
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):
|
||||||
|
api_token = None
|
||||||
|
if os.getenv('JPY_API_TOKEN'):
|
||||||
|
# Deprecated env variable (as of 0.7.2)
|
||||||
|
api_token = os.environ.pop('JPY_API_TOKEN')
|
||||||
|
if os.getenv('JUPYTERHUB_API_TOKEN'):
|
||||||
|
api_token = os.environ.pop('JUPYTERHUB_API_TOKEN')
|
||||||
|
|
||||||
|
if not api_token:
|
||||||
|
self.exit("JUPYTERHUB_API_TOKEN env is required to run jupyterhub-singleuser. Did you launch it manually?")
|
||||||
|
self.hub_auth = HubAuth(
|
||||||
|
parent=self,
|
||||||
|
api_token=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['group'] = self.group
|
||||||
|
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()
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
"""Class for spawning single-user notebook servers."""
|
"""
|
||||||
|
Contains base Spawner class & default implementation
|
||||||
|
"""
|
||||||
|
|
||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
@@ -7,22 +9,26 @@ import errno
|
|||||||
import os
|
import os
|
||||||
import pipes
|
import pipes
|
||||||
import pwd
|
import pwd
|
||||||
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import grp
|
import grp
|
||||||
|
import warnings
|
||||||
from subprocess import Popen
|
from subprocess import Popen
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import mkdtemp
|
||||||
|
|
||||||
from tornado import gen
|
from tornado import gen
|
||||||
from tornado.ioloop import IOLoop, PeriodicCallback
|
from tornado.ioloop import PeriodicCallback
|
||||||
|
|
||||||
from traitlets.config import LoggingConfigurable
|
from traitlets.config import LoggingConfigurable
|
||||||
from traitlets import (
|
from traitlets import (
|
||||||
Any, Bool, Dict, Instance, Integer, Float, List, Unicode,
|
Any, Bool, Dict, Instance, Integer, Float, List, Unicode,
|
||||||
|
validate,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .traitlets import Command
|
from .traitlets import Command, ByteSpecification
|
||||||
from .utils import random_port, localhost
|
from .utils import random_port
|
||||||
|
|
||||||
|
|
||||||
class Spawner(LoggingConfigurable):
|
class Spawner(LoggingConfigurable):
|
||||||
"""Base class for spawning single-user notebook servers.
|
"""Base class for spawning single-user notebook servers.
|
||||||
@@ -34,6 +40,10 @@ class Spawner(LoggingConfigurable):
|
|||||||
- start
|
- start
|
||||||
- stop
|
- stop
|
||||||
- poll
|
- poll
|
||||||
|
|
||||||
|
As JupyterHub supports multiple users, an instance of the Spawner subclass
|
||||||
|
is created for each user. If there are 20 JupyterHub users, there will be 20
|
||||||
|
instances of the subclass.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
db = Any()
|
db = Any()
|
||||||
@@ -41,40 +51,77 @@ class Spawner(LoggingConfigurable):
|
|||||||
hub = Any()
|
hub = Any()
|
||||||
authenticator = Any()
|
authenticator = Any()
|
||||||
api_token = Unicode()
|
api_token = Unicode()
|
||||||
ip = Unicode(localhost(), config=True,
|
|
||||||
help="The IP address (or hostname) the single-user server should listen on"
|
will_resume = Bool(False,
|
||||||
|
help="""Whether the Spawner will resume on next start
|
||||||
|
|
||||||
|
|
||||||
|
Default is False where each launch of the Spawner will be a new instance.
|
||||||
|
If True, an existing Spawner will resume instead of starting anew
|
||||||
|
(e.g. resuming a Docker container),
|
||||||
|
and API tokens in use when the Spawner stops will not be deleted.
|
||||||
|
"""
|
||||||
)
|
)
|
||||||
start_timeout = Integer(60, config=True,
|
|
||||||
help="""Timeout (in seconds) before giving up on the spawner.
|
ip = Unicode('127.0.0.1',
|
||||||
|
help="""
|
||||||
|
The IP address (or hostname) the single-user server should listen on.
|
||||||
|
|
||||||
|
The JupyterHub proxy implementation should be able to send packets to this interface.
|
||||||
|
"""
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
|
port = Integer(0,
|
||||||
|
help="""
|
||||||
|
The port for single-user servers to listen on.
|
||||||
|
|
||||||
|
Defaults to `0`, which uses a randomly allocated port number each time.
|
||||||
|
|
||||||
|
New in version 0.7.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
start_timeout = Integer(60,
|
||||||
|
help="""
|
||||||
|
Timeout (in seconds) before giving up on starting of single-user server.
|
||||||
|
|
||||||
This is the timeout for start to return, not the timeout for the server to respond.
|
This is the timeout for start to return, not the timeout for the server to respond.
|
||||||
Callers of spawner.start will assume that startup has failed if it takes longer than this.
|
Callers of spawner.start will assume that startup has failed if it takes longer than this.
|
||||||
start should return when the server process is started and its location is known.
|
start should return when the server process is started and its location is known.
|
||||||
"""
|
"""
|
||||||
)
|
).tag(config=True)
|
||||||
|
|
||||||
http_timeout = Integer(
|
http_timeout = Integer(30,
|
||||||
30, config=True,
|
help="""
|
||||||
help="""Timeout (in seconds) before giving up on a spawned HTTP server
|
Timeout (in seconds) before giving up on a spawned HTTP server
|
||||||
|
|
||||||
Once a server has successfully been spawned, this is the amount of time
|
Once a server has successfully been spawned, this is the amount of time
|
||||||
we wait before assuming that the server is unable to accept
|
we wait before assuming that the server is unable to accept
|
||||||
connections.
|
connections.
|
||||||
"""
|
"""
|
||||||
)
|
).tag(config=True)
|
||||||
|
|
||||||
|
poll_interval = Integer(30,
|
||||||
|
help="""
|
||||||
|
Interval (in seconds) on which to poll the spawner for single-user server's status.
|
||||||
|
|
||||||
|
At every poll interval, each spawner's `.poll` method is called, which checks
|
||||||
|
if the single-user server is still running. If it isn't running, then JupyterHub modifies
|
||||||
|
its own state accordingly and removes appropriate routes from the configurable proxy.
|
||||||
|
"""
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
poll_interval = Integer(30, config=True,
|
|
||||||
help="""Interval (in seconds) on which to poll the spawner."""
|
|
||||||
)
|
|
||||||
_callbacks = List()
|
_callbacks = List()
|
||||||
_poll_callback = Any()
|
_poll_callback = Any()
|
||||||
|
|
||||||
debug = Bool(False, config=True,
|
debug = Bool(False,
|
||||||
help="Enable debug-logging of the single-user server"
|
help="Enable debug-logging of the single-user server"
|
||||||
)
|
).tag(config=True)
|
||||||
|
|
||||||
options_form = Unicode("", config=True, help="""
|
options_form = Unicode(
|
||||||
|
help="""
|
||||||
An HTML form for options a user can specify on launching their server.
|
An HTML form for options a user can specify on launching their server.
|
||||||
|
|
||||||
The surrounding `<form>` element and the submit button are already provided.
|
The surrounding `<form>` element and the submit button are already provided.
|
||||||
|
|
||||||
For example:
|
For example:
|
||||||
@@ -87,7 +134,9 @@ class Spawner(LoggingConfigurable):
|
|||||||
<option value="A">The letter A</option>
|
<option value="A">The letter A</option>
|
||||||
<option value="B">The letter B</option>
|
<option value="B">The letter B</option>
|
||||||
</select>
|
</select>
|
||||||
""")
|
|
||||||
|
The data from this form submission will be passed on to your spawner in `self.user_options`
|
||||||
|
""").tag(config=True)
|
||||||
|
|
||||||
def options_from_form(self, form_data):
|
def options_from_form(self, form_data):
|
||||||
"""Interpret HTTP form data
|
"""Interpret HTTP form data
|
||||||
@@ -103,7 +152,13 @@ class Spawner(LoggingConfigurable):
|
|||||||
"""
|
"""
|
||||||
return form_data
|
return form_data
|
||||||
|
|
||||||
user_options = Dict(help="This is where form-specified options ultimately end up.")
|
user_options = Dict(
|
||||||
|
help="""
|
||||||
|
Dict of user specified options for the user's spawned instance of a single-user server.
|
||||||
|
|
||||||
|
These user options are usually provided by the `options_form` displayed to the user when they start
|
||||||
|
their server.
|
||||||
|
""")
|
||||||
|
|
||||||
env_keep = List([
|
env_keep = List([
|
||||||
'PATH',
|
'PATH',
|
||||||
@@ -113,32 +168,178 @@ class Spawner(LoggingConfigurable):
|
|||||||
'VIRTUAL_ENV',
|
'VIRTUAL_ENV',
|
||||||
'LANG',
|
'LANG',
|
||||||
'LC_ALL',
|
'LC_ALL',
|
||||||
], config=True,
|
],
|
||||||
help="Whitelist of environment variables for the subprocess to inherit"
|
help="""
|
||||||
)
|
Whitelist of environment variables for the single-user server to inherit from the JupyterHub process.
|
||||||
env = Dict()
|
|
||||||
def _env_default(self):
|
|
||||||
env = {}
|
|
||||||
for key in self.env_keep:
|
|
||||||
if key in os.environ:
|
|
||||||
env[key] = os.environ[key]
|
|
||||||
env['JPY_API_TOKEN'] = self.api_token
|
|
||||||
return env
|
|
||||||
|
|
||||||
cmd = Command(['jupyterhub-singleuser'], config=True,
|
This whitelist is used to ensure that sensitive information in the JupyterHub process's environment
|
||||||
help="""The command used for starting notebooks."""
|
(such as `CONFIGPROXY_AUTH_TOKEN`) is not passed to the single-user server's process.
|
||||||
)
|
|
||||||
args = List(Unicode, config=True,
|
|
||||||
help="""Extra arguments to be passed to the single-user server"""
|
|
||||||
)
|
|
||||||
|
|
||||||
notebook_dir = Unicode('', config=True,
|
|
||||||
help="""The notebook directory for the single-user server
|
|
||||||
|
|
||||||
`~` will be expanded to the user's home directory
|
|
||||||
`%U` will be expanded to the user's username
|
|
||||||
"""
|
"""
|
||||||
)
|
).tag(config=True)
|
||||||
|
|
||||||
|
env = Dict(help="""Deprecated: use Spawner.get_env or Spawner.environment
|
||||||
|
|
||||||
|
- extend Spawner.get_env for adding required env in Spawner subclasses
|
||||||
|
- Spawner.environment for config-specified env
|
||||||
|
""")
|
||||||
|
|
||||||
|
environment = Dict(
|
||||||
|
help="""
|
||||||
|
Extra environment variables to set for the single-user server's process.
|
||||||
|
|
||||||
|
Environment variables that end up in the single-user server's process come from 3 sources:
|
||||||
|
- This `environment` configurable
|
||||||
|
- The JupyterHub process' environment variables that are whitelisted in `env_keep`
|
||||||
|
- Variables to establish contact between the single-user notebook and the hub (such as JUPYTERHUB_API_TOKEN)
|
||||||
|
|
||||||
|
The `enviornment` configurable should be set by JupyterHub administrators to add
|
||||||
|
installation specific environment variables. It is a dict where the key is the name of the environment
|
||||||
|
variable, and the value can be a string or a callable. If it is a callable, it will be called
|
||||||
|
with one parameter (the spawner instance), and should return a string fairly quickly (no blocking
|
||||||
|
operations please!).
|
||||||
|
|
||||||
|
Note that the spawner class' interface is not guaranteed to be exactly same across upgrades,
|
||||||
|
so if you are using the callable take care to verify it continues to work after upgrades!
|
||||||
|
"""
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
|
cmd = Command(['jupyterhub-singleuser'],
|
||||||
|
help="""
|
||||||
|
The command used for starting the single-user server.
|
||||||
|
|
||||||
|
Provide either a string or a list containing the path to the startup script command. Extra arguments,
|
||||||
|
other than this path, should be provided via `args`.
|
||||||
|
|
||||||
|
This is usually set if you want to start the single-user server in a different python
|
||||||
|
environment (with virtualenv/conda) than JupyterHub itself.
|
||||||
|
|
||||||
|
Some spawners allow shell-style expansion here, allowing you to use environment variables.
|
||||||
|
Most, including the default, do not. Consult the documentation for your spawner to verify!
|
||||||
|
"""
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
|
args = List(Unicode(),
|
||||||
|
help="""
|
||||||
|
Extra arguments to be passed to the single-user server.
|
||||||
|
|
||||||
|
Some spawners allow shell-style expansion here, allowing you to use environment variables here.
|
||||||
|
Most, including the default, do not. Consult the documentation for your spawner to verify!
|
||||||
|
"""
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
|
notebook_dir = Unicode(
|
||||||
|
help="""
|
||||||
|
Path to the notebook directory for the single-user server.
|
||||||
|
|
||||||
|
The user sees a file listing of this directory when the notebook interface is started. The
|
||||||
|
current interface does not easily allow browsing beyond the subdirectories in this directory's
|
||||||
|
tree.
|
||||||
|
|
||||||
|
`~` will be expanded to the home directory of the user, and {username} will be replaced
|
||||||
|
with the name of the user.
|
||||||
|
|
||||||
|
Note that this does *not* prevent users from accessing files outside of this path! They
|
||||||
|
can do so with many other means.
|
||||||
|
"""
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
|
default_url = Unicode(
|
||||||
|
help="""
|
||||||
|
The URL the single-user server should start in.
|
||||||
|
|
||||||
|
`{username}` will be expanded to the user's username
|
||||||
|
|
||||||
|
Example uses:
|
||||||
|
- You can set `notebook_dir` to `/` and `default_url` to `/home/{username}` to allow people to
|
||||||
|
navigate the whole filesystem from their notebook, but still start in their home directory.
|
||||||
|
- You can set this to `/lab` to have JupyterLab start by default, rather than Jupyter Notebook.
|
||||||
|
"""
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
|
@validate('notebook_dir', 'default_url')
|
||||||
|
def _deprecate_percent_u(self, proposal):
|
||||||
|
print(proposal)
|
||||||
|
v = proposal['value']
|
||||||
|
if '%U' in v:
|
||||||
|
self.log.warning("%%U for username in %s is deprecated in JupyterHub 0.7, use {username}",
|
||||||
|
proposal['trait'].name,
|
||||||
|
)
|
||||||
|
v = v.replace('%U', '{username}')
|
||||||
|
self.log.warning("Converting %r to %r", proposal['value'], v)
|
||||||
|
return v
|
||||||
|
|
||||||
|
disable_user_config = Bool(False,
|
||||||
|
help="""
|
||||||
|
Disable per-user configuration of single-user servers.
|
||||||
|
|
||||||
|
When starting the user's single-user server, any config file found in the user's $HOME directory
|
||||||
|
will be ignored.
|
||||||
|
|
||||||
|
Note: a user could circumvent this if the user modifies their Python environment, such as when
|
||||||
|
they have their own conda environments / virtualenvs / containers.
|
||||||
|
"""
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
|
mem_limit = ByteSpecification(None,
|
||||||
|
help="""
|
||||||
|
Maximum number of bytes a single-user notebook server is allowed to use.
|
||||||
|
|
||||||
|
Allows the following suffixes:
|
||||||
|
- K -> Kilobytes
|
||||||
|
- M -> Megabytes
|
||||||
|
- G -> Gigabytes
|
||||||
|
- T -> Terabytes
|
||||||
|
|
||||||
|
If the single user server tries to allocate more memory than this,
|
||||||
|
it will fail. There is no guarantee that the single-user notebook server
|
||||||
|
will be able to allocate this much memory - only that it can not
|
||||||
|
allocate more than this.
|
||||||
|
|
||||||
|
This needs to be supported by your spawner for it to work.
|
||||||
|
"""
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
|
cpu_limit = Float(None,
|
||||||
|
allow_none=True,
|
||||||
|
help="""
|
||||||
|
Maximum number of cpu-cores a single-user notebook server is allowed to use.
|
||||||
|
|
||||||
|
If this value is set to 0.5, allows use of 50% of one CPU.
|
||||||
|
If this value is set to 2, allows use of up to 2 CPUs.
|
||||||
|
|
||||||
|
The single-user notebook server will never be scheduled by the kernel to
|
||||||
|
use more cpu-cores than this. There is no guarantee that it can
|
||||||
|
access this many cpu-cores.
|
||||||
|
|
||||||
|
This needs to be supported by your spawner for it to work.
|
||||||
|
"""
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
|
mem_guarantee = ByteSpecification(None,
|
||||||
|
help="""
|
||||||
|
Minimum number of bytes a single-user notebook server is guaranteed to have available.
|
||||||
|
|
||||||
|
Allows the following suffixes:
|
||||||
|
- K -> Kilobytes
|
||||||
|
- M -> Megabytes
|
||||||
|
- G -> Gigabytes
|
||||||
|
- T -> Terabytes
|
||||||
|
|
||||||
|
This needs to be supported by your spawner for it to work.
|
||||||
|
"""
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
|
cpu_guarantee = Float(None,
|
||||||
|
allow_none=True,
|
||||||
|
help="""
|
||||||
|
Minimum number of cpu-cores a single-user notebook server is guaranteed to have available.
|
||||||
|
|
||||||
|
If this value is set to 0.5, allows use of 50% of one CPU.
|
||||||
|
If this value is set to 2, allows use of up to 2 CPUs.
|
||||||
|
|
||||||
|
Note that this needs to be supported by your spawner for it to work.
|
||||||
|
"""
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
super(Spawner, self).__init__(**kwargs)
|
super(Spawner, self).__init__(**kwargs)
|
||||||
@@ -146,29 +347,29 @@ class Spawner(LoggingConfigurable):
|
|||||||
self.load_state(self.user.state)
|
self.load_state(self.user.state)
|
||||||
|
|
||||||
def load_state(self, state):
|
def load_state(self, state):
|
||||||
"""load state from the database
|
"""Restore state of spawner from database.
|
||||||
|
|
||||||
This is the extensible part of state
|
Called for each user's spawner after the hub process restarts.
|
||||||
|
|
||||||
Override in a subclass if there is state to load.
|
`state` is a dict that'll contain the value returned by `get_state` of
|
||||||
Should call `super`.
|
the spawner, or {} if the spawner hasn't persisted any state yet.
|
||||||
|
|
||||||
See Also
|
Override in subclasses to restore any extra state that is needed to track
|
||||||
--------
|
the single-user server for that user. Subclasses should call super().
|
||||||
|
|
||||||
get_state, clear_state
|
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def get_state(self):
|
def get_state(self):
|
||||||
"""store the state necessary for load_state
|
"""Save state of spawner into database.
|
||||||
|
|
||||||
A black box of extra state for custom spawners.
|
A black box of extra state for custom spawners. The returned value of this is
|
||||||
Subclasses should call `super`.
|
passed to `load_state`.
|
||||||
|
|
||||||
|
Subclasses should call `super().get_state()`, augment the state returned from
|
||||||
|
there, and return that state.
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
|
|
||||||
state: dict
|
state: dict
|
||||||
a JSONable dict of state
|
a JSONable dict of state
|
||||||
"""
|
"""
|
||||||
@@ -176,82 +377,204 @@ class Spawner(LoggingConfigurable):
|
|||||||
return state
|
return state
|
||||||
|
|
||||||
def clear_state(self):
|
def clear_state(self):
|
||||||
"""clear any state that should be cleared when the process stops
|
"""Clear any state that should be cleared when the single-user server stops.
|
||||||
|
|
||||||
State that should be preserved across server instances should not be cleared.
|
State that should be preserved across single-user server instances should not be cleared.
|
||||||
|
|
||||||
Subclasses should call super, to ensure that state is properly cleared.
|
Subclasses should call super, to ensure that state is properly cleared.
|
||||||
"""
|
"""
|
||||||
self.api_token = ''
|
self.api_token = ''
|
||||||
|
|
||||||
def get_env(self):
|
def get_env(self):
|
||||||
"""Return the environment we should use
|
"""Return the environment dict to use for the Spawner.
|
||||||
|
|
||||||
|
This applies things like `env_keep`, anything defined in `Spawner.environment`,
|
||||||
|
and adds the API token to the env.
|
||||||
|
|
||||||
|
When overriding in subclasses, subclasses must call `super().get_env()`, extend the
|
||||||
|
returned dict and return it.
|
||||||
|
|
||||||
Default returns a copy of self.env.
|
|
||||||
Use this to access the env in Spawner.start to allow extension in subclasses.
|
Use this to access the env in Spawner.start to allow extension in subclasses.
|
||||||
"""
|
"""
|
||||||
return self.env.copy()
|
env = {}
|
||||||
|
if self.env:
|
||||||
|
warnings.warn("Spawner.env is deprecated, found %s" % self.env, DeprecationWarning)
|
||||||
|
env.update(self.env)
|
||||||
|
|
||||||
|
for key in self.env_keep:
|
||||||
|
if key in os.environ:
|
||||||
|
env[key] = os.environ[key]
|
||||||
|
|
||||||
|
# config overrides. If the value is a callable, it will be called with
|
||||||
|
# one parameter - the current spawner instance - and the return value
|
||||||
|
# will be assigned to the environment variable. This will be called at
|
||||||
|
# spawn time.
|
||||||
|
for key, value in self.environment.items():
|
||||||
|
if callable(value):
|
||||||
|
env[key] = value(self)
|
||||||
|
else:
|
||||||
|
env[key] = value
|
||||||
|
|
||||||
|
env['JUPYTERHUB_API_TOKEN'] = self.api_token
|
||||||
|
# deprecated (as of 0.7.2), for old versions of singleuser
|
||||||
|
env['JPY_API_TOKEN'] = self.api_token
|
||||||
|
|
||||||
|
# Put in limit and guarantee info if they exist.
|
||||||
|
# Note that this is for use by the humans / notebook extensions in the
|
||||||
|
# single-user notebook server, and not for direct usage by the spawners
|
||||||
|
# themselves. Spawners should just use the traitlets directly.
|
||||||
|
if self.mem_limit:
|
||||||
|
env['MEM_LIMIT'] = str(self.mem_limit)
|
||||||
|
if self.mem_guarantee:
|
||||||
|
env['MEM_GUARANTEE'] = str(self.mem_guarantee)
|
||||||
|
if self.cpu_limit:
|
||||||
|
env['CPU_LIMIT'] = str(self.cpu_limit)
|
||||||
|
if self.cpu_guarantee:
|
||||||
|
env['CPU_GUARANTEE'] = str(self.cpu_guarantee)
|
||||||
|
|
||||||
|
return env
|
||||||
|
|
||||||
|
def template_namespace(self):
|
||||||
|
"""Return the template namespace for format-string formatting.
|
||||||
|
|
||||||
|
Currently used on default_url and notebook_dir.
|
||||||
|
|
||||||
|
Subclasses may add items to the available namespace.
|
||||||
|
|
||||||
|
The default implementation includes::
|
||||||
|
|
||||||
|
{
|
||||||
|
'username': user.name,
|
||||||
|
'base_url': users_base_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
|
||||||
|
ns (dict): namespace for string formatting.
|
||||||
|
"""
|
||||||
|
d = {'username': self.user.name}
|
||||||
|
if self.user.server:
|
||||||
|
d['base_url'] = self.user.server.base_url
|
||||||
|
return d
|
||||||
|
|
||||||
|
def format_string(self, s):
|
||||||
|
"""Render a Python format string
|
||||||
|
|
||||||
|
Uses :meth:`Spawner.template_namespace` to populate format namespace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
|
||||||
|
s (str): Python format-string to be formatted.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
|
||||||
|
str: Formatted string, rendered
|
||||||
|
"""
|
||||||
|
return s.format(**self.template_namespace())
|
||||||
|
|
||||||
def get_args(self):
|
def get_args(self):
|
||||||
"""Return the arguments to be passed after self.cmd"""
|
"""Return the arguments to be passed after self.cmd
|
||||||
|
|
||||||
|
Doesn't expect shell expansion to happen.
|
||||||
|
"""
|
||||||
args = [
|
args = [
|
||||||
'--user=%s' % self.user.name,
|
'--user="%s"' % self.user.name,
|
||||||
'--port=%i' % self.user.server.port,
|
'--cookie-name="%s"' % self.user.server.cookie_name,
|
||||||
'--cookie-name=%s' % self.user.server.cookie_name,
|
'--base-url="%s"' % self.user.server.base_url,
|
||||||
'--base-url=%s' % self.user.server.base_url,
|
'--hub-host="%s"' % self.hub.host,
|
||||||
'--hub-prefix=%s' % self.hub.server.base_url,
|
'--hub-prefix="%s"' % self.hub.server.base_url,
|
||||||
'--hub-api-url=%s' % self.hub.api_url,
|
'--hub-api-url="%s"' % self.hub.api_url,
|
||||||
]
|
]
|
||||||
if self.ip:
|
if self.ip:
|
||||||
args.append('--ip=%s' % self.ip)
|
args.append('--ip="%s"' % self.ip)
|
||||||
|
|
||||||
|
if self.port:
|
||||||
|
args.append('--port=%i' % self.port)
|
||||||
|
elif self.user.server.port:
|
||||||
|
self.log.warning("Setting port from user.server is deprecated as of JupyterHub 0.7.")
|
||||||
|
args.append('--port=%i' % self.user.server.port)
|
||||||
|
|
||||||
if self.notebook_dir:
|
if self.notebook_dir:
|
||||||
self.notebook_dir = self.notebook_dir.replace("%U",self.user.name)
|
notebook_dir = self.format_string(self.notebook_dir)
|
||||||
args.append('--notebook-dir=%s' % self.notebook_dir)
|
args.append('--notebook-dir="%s"' % notebook_dir)
|
||||||
|
if self.default_url:
|
||||||
|
default_url = self.format_string(self.default_url)
|
||||||
|
args.append('--NotebookApp.default_url="%s"' % default_url)
|
||||||
|
|
||||||
if self.debug:
|
if self.debug:
|
||||||
args.append('--debug')
|
args.append('--debug')
|
||||||
|
if self.disable_user_config:
|
||||||
|
args.append('--disable-user-config')
|
||||||
args.extend(self.args)
|
args.extend(self.args)
|
||||||
return args
|
return args
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def start(self):
|
def start(self):
|
||||||
"""Start the single-user process"""
|
"""Start the single-user server
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(str, int): the (ip, port) where the Hub can connect to the server.
|
||||||
|
|
||||||
|
.. versionchanged:: 0.7
|
||||||
|
Return ip, port instead of setting on self.user.server directly.
|
||||||
|
"""
|
||||||
raise NotImplementedError("Override in subclass. Must be a Tornado gen.coroutine.")
|
raise NotImplementedError("Override in subclass. Must be a Tornado gen.coroutine.")
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def stop(self, now=False):
|
def stop(self, now=False):
|
||||||
"""Stop the single-user process"""
|
"""Stop the single-user server
|
||||||
|
|
||||||
|
If `now` is set to `False`, do not wait for the server to stop. Otherwise, wait for
|
||||||
|
the server to stop before returning.
|
||||||
|
|
||||||
|
Must be a Tornado coroutine.
|
||||||
|
"""
|
||||||
raise NotImplementedError("Override in subclass. Must be a Tornado gen.coroutine.")
|
raise NotImplementedError("Override in subclass. Must be a Tornado gen.coroutine.")
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def poll(self):
|
def poll(self):
|
||||||
"""Check if the single-user process is running
|
"""Check if the single-user process is running
|
||||||
|
|
||||||
return None if it is, an exit status (0 if unknown) if it is not.
|
Returns:
|
||||||
|
None if single-user process is running.
|
||||||
|
Integer exit status (0 if unknown), if it is not running.
|
||||||
|
|
||||||
|
State transitions, behavior, and return response:
|
||||||
|
|
||||||
|
- If the Spawner has not been initialized (neither loaded state, nor called start),
|
||||||
|
it should behave as if it is not running (status=0).
|
||||||
|
- If the Spawner has not finished starting,
|
||||||
|
it should behave as if it is running (status=None).
|
||||||
|
|
||||||
|
Design assumptions about when `poll` may be called:
|
||||||
|
|
||||||
|
- On Hub launch: `poll` may be called before `start` when state is loaded on Hub launch.
|
||||||
|
`poll` should return exit status 0 (unknown) if the Spawner has not been initialized via
|
||||||
|
`load_state` or `start`.
|
||||||
|
- If `.start()` is async: `poll` may be called during any yielded portions of the `start`
|
||||||
|
process. `poll` should return None when `start` is yielded, indicating that the `start`
|
||||||
|
process has not yet completed.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError("Override in subclass. Must be a Tornado gen.coroutine.")
|
raise NotImplementedError("Override in subclass. Must be a Tornado gen.coroutine.")
|
||||||
|
|
||||||
def add_poll_callback(self, callback, *args, **kwargs):
|
def add_poll_callback(self, callback, *args, **kwargs):
|
||||||
"""add a callback to fire when the subprocess stops
|
"""Add a callback to fire when the single-user server stops"""
|
||||||
|
|
||||||
as noticed by periodic poll_and_notify()
|
|
||||||
"""
|
|
||||||
if args or kwargs:
|
if args or kwargs:
|
||||||
cb = callback
|
cb = callback
|
||||||
callback = lambda : cb(*args, **kwargs)
|
callback = lambda : cb(*args, **kwargs)
|
||||||
self._callbacks.append(callback)
|
self._callbacks.append(callback)
|
||||||
|
|
||||||
def stop_polling(self):
|
def stop_polling(self):
|
||||||
"""stop the periodic poll"""
|
"""Stop polling for single-user server's running state"""
|
||||||
if self._poll_callback:
|
if self._poll_callback:
|
||||||
self._poll_callback.stop()
|
self._poll_callback.stop()
|
||||||
self._poll_callback = None
|
self._poll_callback = None
|
||||||
|
|
||||||
def start_polling(self):
|
def start_polling(self):
|
||||||
"""Start polling periodically
|
"""Start polling periodically for single-user server's running state.
|
||||||
|
|
||||||
callbacks registered via `add_poll_callback` will fire
|
|
||||||
if/when the process stops.
|
|
||||||
|
|
||||||
|
Callbacks registered via `add_poll_callback` will fire if/when the server stops.
|
||||||
Explicit termination via the stop method will not trigger the callbacks.
|
Explicit termination via the stop method will not trigger the callbacks.
|
||||||
"""
|
"""
|
||||||
if self.poll_interval <= 0:
|
if self.poll_interval <= 0:
|
||||||
@@ -270,9 +593,7 @@ class Spawner(LoggingConfigurable):
|
|||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def poll_and_notify(self):
|
def poll_and_notify(self):
|
||||||
"""Used as a callback to periodically poll the process,
|
"""Used as a callback to periodically poll the process and notify any watchers"""
|
||||||
and notify any watchers
|
|
||||||
"""
|
|
||||||
status = yield self.poll()
|
status = yield self.poll()
|
||||||
if status is None:
|
if status is None:
|
||||||
# still running, nothing to do here
|
# still running, nothing to do here
|
||||||
@@ -280,15 +601,17 @@ class Spawner(LoggingConfigurable):
|
|||||||
|
|
||||||
self.stop_polling()
|
self.stop_polling()
|
||||||
|
|
||||||
add_callback = IOLoop.current().add_callback
|
|
||||||
for callback in self._callbacks:
|
for callback in self._callbacks:
|
||||||
add_callback(callback)
|
try:
|
||||||
|
yield gen.maybe_future(callback())
|
||||||
|
except Exception:
|
||||||
|
self.log.exception("Unhandled error in poll callback for %s", self)
|
||||||
|
return status
|
||||||
|
|
||||||
death_interval = Float(0.1)
|
death_interval = Float(0.1)
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def wait_for_death(self, timeout=10):
|
def wait_for_death(self, timeout=10):
|
||||||
"""wait for the process to die, up to timeout seconds"""
|
"""Wait for the single-user server to die, up to timeout seconds"""
|
||||||
loop = IOLoop.current()
|
|
||||||
for i in range(int(timeout / self.death_interval)):
|
for i in range(int(timeout / self.death_interval)):
|
||||||
status = yield self.poll()
|
status = yield self.poll()
|
||||||
if status is not None:
|
if status is not None:
|
||||||
@@ -296,23 +619,32 @@ class Spawner(LoggingConfigurable):
|
|||||||
else:
|
else:
|
||||||
yield gen.sleep(self.death_interval)
|
yield gen.sleep(self.death_interval)
|
||||||
|
|
||||||
|
|
||||||
def _try_setcwd(path):
|
def _try_setcwd(path):
|
||||||
"""Try to set CWD, walking up and ultimately falling back to a temp dir"""
|
"""Try to set CWD to path, walking up until a valid directory is found.
|
||||||
|
|
||||||
|
If no valid directory is found, a temp directory is created and cwd is set to that.
|
||||||
|
"""
|
||||||
while path != '/':
|
while path != '/':
|
||||||
try:
|
try:
|
||||||
os.chdir(path)
|
os.chdir(path)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
|
exc = e # break exception instance out of except scope
|
||||||
print("Couldn't set CWD to %s (%s)" % (path, e), file=sys.stderr)
|
print("Couldn't set CWD to %s (%s)" % (path, e), file=sys.stderr)
|
||||||
path, _ = os.path.split(path)
|
path, _ = os.path.split(path)
|
||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
print("Couldn't set CWD at all (%s), using temp dir" % e, file=sys.stderr)
|
print("Couldn't set CWD at all (%s), using temp dir" % exc, file=sys.stderr)
|
||||||
td = TemporaryDirectory().name
|
td = mkdtemp()
|
||||||
os.chdir(td)
|
os.chdir(td)
|
||||||
|
|
||||||
|
|
||||||
def set_user_setuid(username):
|
def set_user_setuid(username):
|
||||||
"""return a preexec_fn for setting the user (via setuid) of a spawned process"""
|
"""Return a preexec_fn for spawning a single-user server as a particular user.
|
||||||
|
|
||||||
|
Returned preexec_fn will set uid/gid, and attempt to chdir to the target user's
|
||||||
|
home directory.
|
||||||
|
"""
|
||||||
user = pwd.getpwnam(username)
|
user = pwd.getpwnam(username)
|
||||||
uid = user.pw_uid
|
uid = user.pw_uid
|
||||||
gid = user.pw_gid
|
gid = user.pw_gid
|
||||||
@@ -320,7 +652,12 @@ def set_user_setuid(username):
|
|||||||
gids = [ g.gr_gid for g in grp.getgrall() if username in g.gr_mem ]
|
gids = [ g.gr_gid for g in grp.getgrall() if username in g.gr_mem ]
|
||||||
|
|
||||||
def preexec():
|
def preexec():
|
||||||
# set the user and group
|
"""Set uid/gid of current process
|
||||||
|
|
||||||
|
Executed after fork but before exec by python.
|
||||||
|
|
||||||
|
Also try to chdir to the user's home directory.
|
||||||
|
"""
|
||||||
os.setgid(gid)
|
os.setgid(gid)
|
||||||
try:
|
try:
|
||||||
os.setgroups(gids)
|
os.setgroups(gids)
|
||||||
@@ -335,48 +672,87 @@ def set_user_setuid(username):
|
|||||||
|
|
||||||
|
|
||||||
class LocalProcessSpawner(Spawner):
|
class LocalProcessSpawner(Spawner):
|
||||||
"""A Spawner that just uses Popen to start local processes as users.
|
"""
|
||||||
|
A Spawner that uses `subprocess.Popen` to start single-user servers as local processes.
|
||||||
|
|
||||||
Requires users to exist on the local system.
|
Requires local UNIX users matching the authenticated users to exist.
|
||||||
|
Does not work on Windows.
|
||||||
|
|
||||||
This is the default spawner for JupyterHub.
|
This is the default spawner for JupyterHub.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
INTERRUPT_TIMEOUT = Integer(10, config=True,
|
INTERRUPT_TIMEOUT = Integer(10,
|
||||||
help="Seconds to wait for process to halt after SIGINT before proceeding to SIGTERM"
|
help="""
|
||||||
)
|
Seconds to wait for single-user server process to halt after SIGINT.
|
||||||
TERM_TIMEOUT = Integer(5, config=True,
|
|
||||||
help="Seconds to wait for process to halt after SIGTERM before proceeding to SIGKILL"
|
|
||||||
)
|
|
||||||
KILL_TIMEOUT = Integer(5, config=True,
|
|
||||||
help="Seconds to wait for process to halt after SIGKILL before giving up"
|
|
||||||
)
|
|
||||||
|
|
||||||
proc = Instance(Popen, allow_none=True)
|
If the process has not exited cleanly after this many seconds, a SIGTERM is sent.
|
||||||
pid = Integer(0)
|
"""
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
|
TERM_TIMEOUT = Integer(5,
|
||||||
|
help="""
|
||||||
|
Seconds to wait for single-user server process to halt after SIGTERM.
|
||||||
|
|
||||||
|
If the process does not exit cleanly after this many seconds of SIGTERM, a SIGKILL is sent.
|
||||||
|
"""
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
|
KILL_TIMEOUT = Integer(5,
|
||||||
|
help="""
|
||||||
|
Seconds to wait for process to halt after SIGKILL before giving up.
|
||||||
|
|
||||||
|
If the process does not exit cleanly after this many seconds of SIGKILL, it becomes a zombie
|
||||||
|
process. The hub process will log a warning and then give up.
|
||||||
|
"""
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
|
proc = Instance(Popen,
|
||||||
|
allow_none=True,
|
||||||
|
help="""
|
||||||
|
The process representing the single-user server process spawned for current user.
|
||||||
|
|
||||||
|
Is None if no process has been spawned yet.
|
||||||
|
""")
|
||||||
|
pid = Integer(0,
|
||||||
|
help="""
|
||||||
|
The process id (pid) of the single-user server process spawned for current user.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
def make_preexec_fn(self, name):
|
def make_preexec_fn(self, name):
|
||||||
|
"""
|
||||||
|
Return a function that can be used to set the user id of the spawned process to user with name `name`
|
||||||
|
|
||||||
|
This function can be safely passed to `preexec_fn` of `Popen`
|
||||||
|
"""
|
||||||
return set_user_setuid(name)
|
return set_user_setuid(name)
|
||||||
|
|
||||||
def load_state(self, state):
|
def load_state(self, state):
|
||||||
"""load pid from state"""
|
"""Restore state about spawned single-user server after a hub restart.
|
||||||
|
|
||||||
|
Local processes only need the process id.
|
||||||
|
"""
|
||||||
super(LocalProcessSpawner, self).load_state(state)
|
super(LocalProcessSpawner, self).load_state(state)
|
||||||
if 'pid' in state:
|
if 'pid' in state:
|
||||||
self.pid = state['pid']
|
self.pid = state['pid']
|
||||||
|
|
||||||
def get_state(self):
|
def get_state(self):
|
||||||
"""add pid to state"""
|
"""Save state that is needed to restore this spawner instance after a hub restore.
|
||||||
|
|
||||||
|
Local processes only need the process id.
|
||||||
|
"""
|
||||||
state = super(LocalProcessSpawner, self).get_state()
|
state = super(LocalProcessSpawner, self).get_state()
|
||||||
if self.pid:
|
if self.pid:
|
||||||
state['pid'] = self.pid
|
state['pid'] = self.pid
|
||||||
return state
|
return state
|
||||||
|
|
||||||
def clear_state(self):
|
def clear_state(self):
|
||||||
"""clear pid state"""
|
"""Clear stored state about this spawner (pid)"""
|
||||||
super(LocalProcessSpawner, self).clear_state()
|
super(LocalProcessSpawner, self).clear_state()
|
||||||
self.pid = 0
|
self.pid = 0
|
||||||
|
|
||||||
def user_env(self, env):
|
def user_env(self, env):
|
||||||
|
"""Augment environment of spawned process with user specific env variables."""
|
||||||
env['USER'] = self.user.name
|
env['USER'] = self.user.name
|
||||||
home = pwd.getpwnam(self.user.name).pw_dir
|
home = pwd.getpwnam(self.user.name).pw_dir
|
||||||
shell = pwd.getpwnam(self.user.name).pw_shell
|
shell = pwd.getpwnam(self.user.name).pw_shell
|
||||||
@@ -389,17 +765,15 @@ class LocalProcessSpawner(Spawner):
|
|||||||
return env
|
return env
|
||||||
|
|
||||||
def get_env(self):
|
def get_env(self):
|
||||||
"""Add user environment variables"""
|
"""Get the complete set of environment variables to be set in the spawned process."""
|
||||||
env = super().get_env()
|
env = super().get_env()
|
||||||
env = self.user_env(env)
|
env = self.user_env(env)
|
||||||
return env
|
return env
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def start(self):
|
def start(self):
|
||||||
"""Start the process"""
|
"""Start the single-user server."""
|
||||||
if self.ip:
|
self.port = random_port()
|
||||||
self.user.server.ip = self.ip
|
|
||||||
self.user.server.port = random_port()
|
|
||||||
cmd = []
|
cmd = []
|
||||||
env = self.get_env()
|
env = self.get_env()
|
||||||
|
|
||||||
@@ -407,15 +781,39 @@ class LocalProcessSpawner(Spawner):
|
|||||||
cmd.extend(self.get_args())
|
cmd.extend(self.get_args())
|
||||||
|
|
||||||
self.log.info("Spawning %s", ' '.join(pipes.quote(s) for s in cmd))
|
self.log.info("Spawning %s", ' '.join(pipes.quote(s) for s in cmd))
|
||||||
self.proc = Popen(cmd, env=env,
|
try:
|
||||||
preexec_fn=self.make_preexec_fn(self.user.name),
|
self.proc = Popen(cmd, env=env,
|
||||||
start_new_session=True, # don't forward signals
|
preexec_fn=self.make_preexec_fn(self.user.name),
|
||||||
)
|
start_new_session=True, # don't forward signals
|
||||||
|
)
|
||||||
|
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
|
self.pid = self.proc.pid
|
||||||
|
|
||||||
|
if self.__class__ is not LocalProcessSpawner:
|
||||||
|
# subclasses may not pass through return value of super().start,
|
||||||
|
# relying on deprecated 0.6 way of setting ip, port,
|
||||||
|
# so keep a redundant copy here for now.
|
||||||
|
# A deprecation warning will be shown if the subclass
|
||||||
|
# does not return ip, port.
|
||||||
|
if self.ip:
|
||||||
|
self.user.server.ip = self.ip
|
||||||
|
self.user.server.port = self.port
|
||||||
|
return (self.ip or '127.0.0.1', self.port)
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def poll(self):
|
def poll(self):
|
||||||
"""Poll the process"""
|
"""Poll the spawned process to see if it is still running.
|
||||||
|
|
||||||
|
If the process is still running, we return None. If it is not running,
|
||||||
|
we return the exit code of the process if we have access to it, or 0 otherwise.
|
||||||
|
"""
|
||||||
# if we started the process, poll with Popen
|
# if we started the process, poll with Popen
|
||||||
if self.proc is not None:
|
if self.proc is not None:
|
||||||
status = self.proc.poll()
|
status = self.proc.poll()
|
||||||
@@ -426,7 +824,6 @@ class LocalProcessSpawner(Spawner):
|
|||||||
|
|
||||||
# if we resumed from stored state,
|
# if we resumed from stored state,
|
||||||
# we don't have the Popen handle anymore, so rely on self.pid
|
# we don't have the Popen handle anymore, so rely on self.pid
|
||||||
|
|
||||||
if not self.pid:
|
if not self.pid:
|
||||||
# no pid, not running
|
# no pid, not running
|
||||||
self.clear_state()
|
self.clear_state()
|
||||||
@@ -443,7 +840,12 @@ class LocalProcessSpawner(Spawner):
|
|||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def _signal(self, sig):
|
def _signal(self, sig):
|
||||||
"""simple implementation of signal, which we can use when we are using setuid (we are root)"""
|
"""Send given signal to a single-user server's process.
|
||||||
|
|
||||||
|
Returns True if the process still exists, False otherwise.
|
||||||
|
|
||||||
|
The hub process is assumed to have enough privileges to do this (e.g. root).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
os.kill(self.pid, sig)
|
os.kill(self.pid, sig)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
@@ -455,9 +857,10 @@ class LocalProcessSpawner(Spawner):
|
|||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def stop(self, now=False):
|
def stop(self, now=False):
|
||||||
"""stop the subprocess
|
"""Stop the single-user server process for the current user.
|
||||||
|
|
||||||
if `now`, skip waiting for clean shutdown
|
If `now` is set to True, do not wait for the process to die.
|
||||||
|
Otherwise, it'll wait.
|
||||||
"""
|
"""
|
||||||
if not now:
|
if not now:
|
||||||
status = yield self.poll()
|
status = yield self.poll()
|
||||||
@@ -486,5 +889,4 @@ class LocalProcessSpawner(Spawner):
|
|||||||
status = yield self.poll()
|
status = yield self.poll()
|
||||||
if status is None:
|
if status is None:
|
||||||
# it all failed, zombie process
|
# it all failed, zombie process
|
||||||
self.log.warn("Process %i never died", self.pid)
|
self.log.warning("Process %i never died", self.pid)
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,19 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from getpass import getuser
|
from getpass import getuser
|
||||||
|
from subprocess import TimeoutExpired
|
||||||
from pytest import fixture
|
import time
|
||||||
|
from unittest import mock
|
||||||
|
from pytest import fixture, yield_fixture, raises
|
||||||
from tornado import ioloop
|
from tornado import ioloop
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
|
from ..utils import random_port
|
||||||
|
|
||||||
from .mocking import MockHub
|
from .mocking import MockHub
|
||||||
|
from .test_services import mockservice_cmd
|
||||||
|
|
||||||
|
import jupyterhub.services.service
|
||||||
|
|
||||||
# global db session object
|
# global db session object
|
||||||
_db = None
|
_db = None
|
||||||
@@ -53,3 +58,58 @@ def app(request):
|
|||||||
app.stop()
|
app.stop()
|
||||||
request.addfinalizer(fin)
|
request.addfinalizer(fin)
|
||||||
return app
|
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"""
|
"""mock utilities for testing"""
|
||||||
|
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from datetime import timedelta
|
|
||||||
from tempfile import NamedTemporaryFile
|
from tempfile import NamedTemporaryFile
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
@@ -13,11 +13,14 @@ from tornado import gen
|
|||||||
from tornado.concurrent import Future
|
from tornado.concurrent import Future
|
||||||
from tornado.ioloop import IOLoop
|
from tornado.ioloop import IOLoop
|
||||||
|
|
||||||
from ..spawner import LocalProcessSpawner
|
from traitlets import default
|
||||||
|
|
||||||
from ..app import JupyterHub
|
from ..app import JupyterHub
|
||||||
from ..auth import PAMAuthenticator
|
from ..auth import PAMAuthenticator
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from ..utils import localhost
|
from ..spawner import LocalProcessSpawner
|
||||||
|
from ..singleuser import SingleUserNotebookApp
|
||||||
|
from ..utils import random_port
|
||||||
|
|
||||||
from pamela import PAMError
|
from pamela import PAMError
|
||||||
|
|
||||||
@@ -34,7 +37,11 @@ def mock_open_session(username, service):
|
|||||||
|
|
||||||
|
|
||||||
class MockSpawner(LocalProcessSpawner):
|
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):
|
def make_preexec_fn(self, *a, **kw):
|
||||||
# skip the setuid stuff
|
# skip the setuid stuff
|
||||||
return
|
return
|
||||||
@@ -45,6 +52,7 @@ class MockSpawner(LocalProcessSpawner):
|
|||||||
def user_env(self, env):
|
def user_env(self, env):
|
||||||
return env
|
return env
|
||||||
|
|
||||||
|
@default('cmd')
|
||||||
def _cmd_default(self):
|
def _cmd_default(self):
|
||||||
return [sys.executable, '-m', 'jupyterhub.tests.mocksu']
|
return [sys.executable, '-m', 'jupyterhub.tests.mocksu']
|
||||||
|
|
||||||
@@ -54,8 +62,9 @@ class SlowSpawner(MockSpawner):
|
|||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def start(self):
|
def start(self):
|
||||||
yield super().start()
|
(ip, port) = yield super().start()
|
||||||
yield gen.sleep(2)
|
yield gen.sleep(2)
|
||||||
|
return ip, port
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def stop(self):
|
def stop(self):
|
||||||
@@ -66,6 +75,7 @@ class SlowSpawner(MockSpawner):
|
|||||||
class NeverSpawner(MockSpawner):
|
class NeverSpawner(MockSpawner):
|
||||||
"""A spawner that will never start"""
|
"""A spawner that will never start"""
|
||||||
|
|
||||||
|
@default('start_timeout')
|
||||||
def _start_timeout_default(self):
|
def _start_timeout_default(self):
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
@@ -75,6 +85,7 @@ class NeverSpawner(MockSpawner):
|
|||||||
|
|
||||||
|
|
||||||
class FormSpawner(MockSpawner):
|
class FormSpawner(MockSpawner):
|
||||||
|
"""A spawner that has an options form defined"""
|
||||||
options_form = "IMAFORM"
|
options_form = "IMAFORM"
|
||||||
|
|
||||||
def options_from_form(self, form_data):
|
def options_from_form(self, form_data):
|
||||||
@@ -90,6 +101,7 @@ class FormSpawner(MockSpawner):
|
|||||||
|
|
||||||
|
|
||||||
class MockPAMAuthenticator(PAMAuthenticator):
|
class MockPAMAuthenticator(PAMAuthenticator):
|
||||||
|
@default('admin_users')
|
||||||
def _admin_users_default(self):
|
def _admin_users_default(self):
|
||||||
return {'admin'}
|
return {'admin'}
|
||||||
|
|
||||||
@@ -105,17 +117,29 @@ class MockPAMAuthenticator(PAMAuthenticator):
|
|||||||
):
|
):
|
||||||
return super(MockPAMAuthenticator, self).authenticate(*args, **kwargs)
|
return super(MockPAMAuthenticator, self).authenticate(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class MockHub(JupyterHub):
|
class MockHub(JupyterHub):
|
||||||
"""Hub with various mock bits"""
|
"""Hub with various mock bits"""
|
||||||
|
|
||||||
db_file = None
|
db_file = None
|
||||||
|
|
||||||
def _ip_default(self):
|
last_activity_interval = 2
|
||||||
return localhost()
|
|
||||||
|
|
||||||
|
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):
|
def _authenticator_class_default(self):
|
||||||
return MockPAMAuthenticator
|
return MockPAMAuthenticator
|
||||||
|
|
||||||
|
@default('spawner_class')
|
||||||
def _spawner_class_default(self):
|
def _spawner_class_default(self):
|
||||||
return MockSpawner
|
return MockSpawner
|
||||||
|
|
||||||
@@ -124,7 +148,8 @@ class MockHub(JupyterHub):
|
|||||||
|
|
||||||
def start(self, argv=None):
|
def start(self, argv=None):
|
||||||
self.db_file = NamedTemporaryFile()
|
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()
|
evt = threading.Event()
|
||||||
|
|
||||||
@@ -161,13 +186,90 @@ class MockHub(JupyterHub):
|
|||||||
self.db_file.close()
|
self.db_file.close()
|
||||||
|
|
||||||
def login_user(self, name):
|
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={
|
data={
|
||||||
'username': name,
|
'username': name,
|
||||||
'password': name,
|
'password': name,
|
||||||
},
|
},
|
||||||
allow_redirects=False,
|
allow_redirects=False,
|
||||||
)
|
)
|
||||||
|
r.raise_for_status()
|
||||||
assert r.cookies
|
assert r.cookies
|
||||||
return 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 json
|
||||||
import time
|
import time
|
||||||
from datetime import timedelta
|
|
||||||
from queue import Queue
|
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
|
import requests
|
||||||
|
|
||||||
from tornado import gen
|
from tornado import gen
|
||||||
|
|
||||||
|
import jupyterhub
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from ..user import User
|
from ..user import User
|
||||||
from ..utils import url_path_join as ujoin
|
from ..utils import url_path_join as ujoin
|
||||||
from . import mocking
|
from . import mocking
|
||||||
|
from .mocking import public_host, public_url
|
||||||
|
|
||||||
|
|
||||||
def check_db_locks(func):
|
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
|
Decorator for test functions that verifies no locks are held on the
|
||||||
application's database upon exit by creating and dropping a dummy table.
|
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.
|
decorated function.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def new_func(*args, **kwargs):
|
def new_func(app, *args, **kwargs):
|
||||||
retval = func(*args, **kwargs)
|
retval = func(app, *args, **kwargs)
|
||||||
|
|
||||||
app = args[0]
|
|
||||||
temp_session = app.session_factory()
|
temp_session = app.session_factory()
|
||||||
temp_session.execute('CREATE TABLE dummy (foo INT)')
|
temp_session.execute('CREATE TABLE dummy (foo INT)')
|
||||||
temp_session.execute('DROP TABLE dummy')
|
temp_session.execute('DROP TABLE dummy')
|
||||||
@@ -43,8 +46,13 @@ def find_user(db, name):
|
|||||||
return db.query(orm.User).filter(orm.User.name==name).first()
|
return db.query(orm.User).filter(orm.User.name==name).first()
|
||||||
|
|
||||||
def add_user(db, app=None, **kwargs):
|
def add_user(db, app=None, **kwargs):
|
||||||
orm_user = orm.User(**kwargs)
|
orm_user = find_user(db, name=kwargs.get('name'))
|
||||||
db.add(orm_user)
|
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()
|
db.commit()
|
||||||
if app:
|
if app:
|
||||||
user = app.users[orm_user.id] = User(orm_user, app.tornado_settings)
|
user = app.users[orm_user.id] = User(orm_user, app.tornado_settings)
|
||||||
@@ -105,7 +113,7 @@ def test_auth_api(app):
|
|||||||
|
|
||||||
|
|
||||||
def test_referer_check(app, io_loop):
|
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
|
host = urlparse(url).netloc
|
||||||
user = find_user(app.db, 'admin')
|
user = find_user(app.db, 'admin')
|
||||||
if user is None:
|
if user is None:
|
||||||
@@ -147,7 +155,9 @@ def test_referer_check(app, io_loop):
|
|||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
# user API tests
|
||||||
|
|
||||||
|
@mark.user
|
||||||
def test_get_users(app):
|
def test_get_users(app):
|
||||||
db = app.db
|
db = app.db
|
||||||
r = api_request(app, 'users')
|
r = api_request(app, 'users')
|
||||||
@@ -159,12 +169,14 @@ def test_get_users(app):
|
|||||||
assert users == [
|
assert users == [
|
||||||
{
|
{
|
||||||
'name': 'admin',
|
'name': 'admin',
|
||||||
|
'groups': [],
|
||||||
'admin': True,
|
'admin': True,
|
||||||
'server': None,
|
'server': None,
|
||||||
'pending': None,
|
'pending': None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'user',
|
'name': 'user',
|
||||||
|
'groups': [],
|
||||||
'admin': False,
|
'admin': False,
|
||||||
'server': None,
|
'server': None,
|
||||||
'pending': None,
|
'pending': None,
|
||||||
@@ -176,6 +188,8 @@ def test_get_users(app):
|
|||||||
)
|
)
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@mark.user
|
||||||
def test_add_user(app):
|
def test_add_user(app):
|
||||||
db = app.db
|
db = app.db
|
||||||
name = 'newuser'
|
name = 'newuser'
|
||||||
@@ -187,6 +201,7 @@ def test_add_user(app):
|
|||||||
assert not user.admin
|
assert not user.admin
|
||||||
|
|
||||||
|
|
||||||
|
@mark.user
|
||||||
def test_get_user(app):
|
def test_get_user(app):
|
||||||
name = 'user'
|
name = 'user'
|
||||||
r = api_request(app, 'users', name)
|
r = api_request(app, 'users', name)
|
||||||
@@ -195,12 +210,14 @@ def test_get_user(app):
|
|||||||
user.pop('last_activity')
|
user.pop('last_activity')
|
||||||
assert user == {
|
assert user == {
|
||||||
'name': name,
|
'name': name,
|
||||||
|
'groups': [],
|
||||||
'admin': False,
|
'admin': False,
|
||||||
'server': None,
|
'server': None,
|
||||||
'pending': None,
|
'pending': None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mark.user
|
||||||
def test_add_multi_user_bad(app):
|
def test_add_multi_user_bad(app):
|
||||||
r = api_request(app, 'users', method='post')
|
r = api_request(app, 'users', method='post')
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
@@ -210,6 +227,7 @@ def test_add_multi_user_bad(app):
|
|||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@mark.user
|
||||||
def test_add_multi_user_invalid(app):
|
def test_add_multi_user_invalid(app):
|
||||||
app.authenticator.username_pattern = r'w.*'
|
app.authenticator.username_pattern = r'w.*'
|
||||||
r = api_request(app, 'users', method='post',
|
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'
|
assert r.json()['message'] == 'Invalid usernames: andrew, tara'
|
||||||
|
|
||||||
|
|
||||||
|
@mark.user
|
||||||
def test_add_multi_user(app):
|
def test_add_multi_user(app):
|
||||||
db = app.db
|
db = app.db
|
||||||
names = ['a', 'b']
|
names = ['a', 'b']
|
||||||
@@ -255,6 +274,7 @@ def test_add_multi_user(app):
|
|||||||
assert r_names == ['ab']
|
assert r_names == ['ab']
|
||||||
|
|
||||||
|
|
||||||
|
@mark.user
|
||||||
def test_add_multi_user_admin(app):
|
def test_add_multi_user_admin(app):
|
||||||
db = app.db
|
db = app.db
|
||||||
names = ['c', 'd']
|
names = ['c', 'd']
|
||||||
@@ -273,6 +293,7 @@ def test_add_multi_user_admin(app):
|
|||||||
assert user.admin
|
assert user.admin
|
||||||
|
|
||||||
|
|
||||||
|
@mark.user
|
||||||
def test_add_user_bad(app):
|
def test_add_user_bad(app):
|
||||||
db = app.db
|
db = app.db
|
||||||
name = 'dne_newuser'
|
name = 'dne_newuser'
|
||||||
@@ -281,6 +302,8 @@ def test_add_user_bad(app):
|
|||||||
user = find_user(db, name)
|
user = find_user(db, name)
|
||||||
assert user is None
|
assert user is None
|
||||||
|
|
||||||
|
|
||||||
|
@mark.user
|
||||||
def test_add_admin(app):
|
def test_add_admin(app):
|
||||||
db = app.db
|
db = app.db
|
||||||
name = 'newadmin'
|
name = 'newadmin'
|
||||||
@@ -293,6 +316,8 @@ def test_add_admin(app):
|
|||||||
assert user.name == name
|
assert user.name == name
|
||||||
assert user.admin
|
assert user.admin
|
||||||
|
|
||||||
|
|
||||||
|
@mark.user
|
||||||
def test_delete_user(app):
|
def test_delete_user(app):
|
||||||
db = app.db
|
db = app.db
|
||||||
mal = add_user(db, name='mal')
|
mal = add_user(db, name='mal')
|
||||||
@@ -300,6 +325,7 @@ def test_delete_user(app):
|
|||||||
assert r.status_code == 204
|
assert r.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
@mark.user
|
||||||
def test_make_admin(app):
|
def test_make_admin(app):
|
||||||
db = app.db
|
db = app.db
|
||||||
name = 'admin2'
|
name = 'admin2'
|
||||||
@@ -319,6 +345,7 @@ def test_make_admin(app):
|
|||||||
assert user.name == name
|
assert user.name == name
|
||||||
assert user.admin
|
assert user.admin
|
||||||
|
|
||||||
|
|
||||||
def get_app_user(app, name):
|
def get_app_user(app, name):
|
||||||
"""Get the User object from the main thread
|
"""Get the User object from the main thread
|
||||||
|
|
||||||
@@ -333,6 +360,7 @@ def get_app_user(app, name):
|
|||||||
user_id = q.get(timeout=2)
|
user_id = q.get(timeout=2)
|
||||||
return app.users[user_id]
|
return app.users[user_id]
|
||||||
|
|
||||||
|
|
||||||
def test_spawn(app, io_loop):
|
def test_spawn(app, io_loop):
|
||||||
db = app.db
|
db = app.db
|
||||||
name = 'wash'
|
name = 'wash'
|
||||||
@@ -341,6 +369,7 @@ def test_spawn(app, io_loop):
|
|||||||
's': ['value'],
|
's': ['value'],
|
||||||
'i': 5,
|
'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))
|
r = api_request(app, 'users', name, 'server', method='post', data=json.dumps(options))
|
||||||
assert r.status_code == 201
|
assert r.status_code == 201
|
||||||
assert 'pid' in user.state
|
assert 'pid' in user.state
|
||||||
@@ -351,16 +380,20 @@ def test_spawn(app, io_loop):
|
|||||||
status = io_loop.run_sync(app_user.spawner.poll)
|
status = io_loop.run_sync(app_user.spawner.poll)
|
||||||
assert status is None
|
assert status is None
|
||||||
|
|
||||||
assert user.server.base_url == '/user/%s' % name
|
assert user.server.base_url == ujoin(app.base_url, 'user/%s' % name)
|
||||||
r = requests.get(ujoin(app.proxy.public_server.url, user.server.base_url))
|
url = public_url(app, user)
|
||||||
|
print(url)
|
||||||
|
r = requests.get(url)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.text == user.server.base_url
|
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
|
assert r.status_code == 200
|
||||||
argv = r.json()
|
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
|
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')
|
r = api_request(app, 'users', name, 'server', method='delete')
|
||||||
assert r.status_code == 204
|
assert r.status_code == 204
|
||||||
@@ -369,12 +402,18 @@ def test_spawn(app, io_loop):
|
|||||||
status = io_loop.run_sync(app_user.spawner.poll)
|
status = io_loop.run_sync(app_user.spawner.poll)
|
||||||
assert status == 0
|
assert status == 0
|
||||||
|
|
||||||
def test_slow_spawn(app, io_loop):
|
# check that we cleaned up after ourselves
|
||||||
# app.tornado_application.settings['spawner_class'] = mocking.SlowSpawner
|
assert user.server is None
|
||||||
app.tornado_settings['spawner_class'] = mocking.SlowSpawner
|
after_servers = sorted(db.query(orm.Server), key=lambda s: s.url)
|
||||||
app.tornado_application.settings['slow_spawn_timeout'] = 0
|
assert before_servers == after_servers
|
||||||
app.tornado_application.settings['slow_stop_timeout'] = 0
|
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
|
db = app.db
|
||||||
name = 'zoe'
|
name = 'zoe'
|
||||||
user = add_user(db, app=app, name=name)
|
user = add_user(db, app=app, name=name)
|
||||||
@@ -420,9 +459,10 @@ def test_slow_spawn(app, io_loop):
|
|||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_never_spawn(app, io_loop):
|
def test_never_spawn(app, io_loop, no_patience, request):
|
||||||
app.tornado_settings['spawner_class'] = mocking.NeverSpawner
|
patch = mock.patch.dict(app.tornado_settings, {'spawner_class': mocking.NeverSpawner})
|
||||||
app.tornado_application.settings['slow_spawn_timeout'] = 0
|
patch.start()
|
||||||
|
request.addfinalizer(patch.stop)
|
||||||
|
|
||||||
db = app.db
|
db = app.db
|
||||||
name = 'badger'
|
name = 'badger'
|
||||||
@@ -450,6 +490,295 @@ def test_get_proxy(app, io_loop):
|
|||||||
assert list(reply.keys()) == ['/']
|
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):
|
def test_shutdown(app):
|
||||||
r = api_request(app, 'shutdown', method='post', data=json.dumps({
|
r = api_request(app, 'shutdown', method='post', data=json.dumps({
|
||||||
'servers': True,
|
'servers': True,
|
||||||
@@ -463,3 +792,4 @@ def test_shutdown(app):
|
|||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
assert not app.io_loop._running
|
assert not app.io_loop._running
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
"""Test the JupyterHub entry point"""
|
"""Test the JupyterHub entry point"""
|
||||||
|
|
||||||
|
import binascii
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from subprocess import check_output
|
from subprocess import check_output, Popen, PIPE
|
||||||
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from .mocking import MockHub
|
||||||
|
from .. import orm
|
||||||
|
|
||||||
def test_help_all():
|
def test_help_all():
|
||||||
out = check_output([sys.executable, '-m', 'jupyterhub', '--help-all']).decode('utf8', 'replace')
|
out = check_output([sys.executable, '-m', 'jupyterhub', '--help-all']).decode('utf8', 'replace')
|
||||||
@@ -23,10 +30,23 @@ def test_token_app():
|
|||||||
def test_generate_config():
|
def test_generate_config():
|
||||||
with NamedTemporaryFile(prefix='jupyterhub_config', suffix='.py') as tf:
|
with NamedTemporaryFile(prefix='jupyterhub_config', suffix='.py') as tf:
|
||||||
cfg_file = tf.name
|
cfg_file = tf.name
|
||||||
|
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'
|
||||||
|
|
||||||
out = check_output([sys.executable, '-m', 'jupyterhub',
|
p = Popen([sys.executable, '-m', 'jupyterhub',
|
||||||
'--generate-config', '-f', cfg_file]
|
'--generate-config', '-f', cfg_file],
|
||||||
).decode('utf8', 'replace')
|
stdout=PIPE, stdin=PIPE)
|
||||||
|
out, _ = p.communicate(b'x\ny')
|
||||||
|
out = out.decode('utf8', 'replace')
|
||||||
assert os.path.exists(cfg_file)
|
assert os.path.exists(cfg_file)
|
||||||
with open(cfg_file) as f:
|
with open(cfg_file) as f:
|
||||||
cfg_text = f.read()
|
cfg_text = f.read()
|
||||||
@@ -34,3 +54,105 @@ def test_generate_config():
|
|||||||
assert cfg_file in out
|
assert cfg_file in out
|
||||||
assert 'Spawner.cmd' in cfg_text
|
assert 'Spawner.cmd' in cfg_text
|
||||||
assert 'Authenticator.whitelist' in cfg_text
|
assert 'Authenticator.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'
|
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):
|
def test_username_map(io_loop):
|
||||||
a = MockPAMAuthenticator(username_map={'wash': 'alpha'})
|
a = MockPAMAuthenticator(username_map={'wash': 'alpha'})
|
||||||
@@ -189,6 +206,9 @@ def test_validate_names(io_loop):
|
|||||||
a = auth.PAMAuthenticator()
|
a = auth.PAMAuthenticator()
|
||||||
assert a.validate_username('willow')
|
assert a.validate_username('willow')
|
||||||
assert a.validate_username('giles')
|
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.*')
|
a = auth.PAMAuthenticator(username_pattern='w.*')
|
||||||
assert not a.validate_username('xander')
|
assert not a.validate_username('xander')
|
||||||
assert a.validate_username('willow')
|
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 server.proto == 'http'
|
||||||
assert isinstance(server.port, int)
|
assert isinstance(server.port, int)
|
||||||
assert isinstance(server.cookie_name, str)
|
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.url == server.host + '/'
|
||||||
assert server.bind_url == 'http://*:%i/' % server.port
|
assert server.bind_url == 'http://*:%i/' % server.port
|
||||||
server.ip = '127.0.0.1'
|
server.ip = '127.0.0.1'
|
||||||
@@ -90,9 +90,82 @@ def test_tokens(db):
|
|||||||
assert len(user.api_tokens) == 2
|
assert len(user.api_tokens) == 2
|
||||||
found = orm.APIToken.find(db, token=token)
|
found = orm.APIToken.find(db, token=token)
|
||||||
assert found.match(token)
|
assert found.match(token)
|
||||||
|
assert found.user is user
|
||||||
|
assert found.service is None
|
||||||
found = orm.APIToken.find(db, 'something else')
|
found = orm.APIToken.find(db, 'something else')
|
||||||
assert found is None
|
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):
|
def test_spawn_fails(db, io_loop):
|
||||||
orm_user = orm.User(name='aeofel')
|
orm_user = orm.User(name='aeofel')
|
||||||
@@ -114,3 +187,17 @@ def test_spawn_fails(db, io_loop):
|
|||||||
assert user.server is None
|
assert user.server is None
|
||||||
assert not user.running
|
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"""
|
"""Tests for HTML pages"""
|
||||||
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlencode, urlparse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
@@ -8,12 +8,15 @@ from ..utils import url_path_join as ujoin
|
|||||||
from .. import orm
|
from .. import orm
|
||||||
|
|
||||||
import mock
|
import mock
|
||||||
from .mocking import FormSpawner
|
from .mocking import FormSpawner, public_url, public_host
|
||||||
from .test_api import api_request
|
from .test_api import api_request
|
||||||
|
|
||||||
|
def get_page(path, app, hub=True, **kw):
|
||||||
def get_page(path, app, **kw):
|
if hub:
|
||||||
base_url = ujoin(app.proxy.public_server.host, app.hub.server.base_url)
|
prefix = app.hub.server.base_url
|
||||||
|
else:
|
||||||
|
prefix = app.base_url
|
||||||
|
base_url = ujoin(public_host(app), prefix)
|
||||||
print(base_url)
|
print(base_url)
|
||||||
return requests.get(ujoin(base_url, path), **kw)
|
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)
|
routes = io_loop.run_sync(app.proxy.get_routes)
|
||||||
print(routes)
|
print(routes)
|
||||||
print(app.hub.server)
|
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()
|
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):
|
def test_root_auth(app):
|
||||||
cookies = app.login_user('river')
|
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()
|
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):
|
def test_home_no_auth(app):
|
||||||
r = get_page('home', app, allow_redirects=False)
|
r = get_page('home', app, allow_redirects=False)
|
||||||
@@ -62,6 +77,7 @@ def test_admin(app):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.url.endswith('/admin')
|
assert r.url.endswith('/admin')
|
||||||
|
|
||||||
|
|
||||||
def test_spawn_redirect(app, io_loop):
|
def test_spawn_redirect(app, io_loop):
|
||||||
name = 'wash'
|
name = 'wash'
|
||||||
cookies = app.login_user(name)
|
cookies = app.login_user(name)
|
||||||
@@ -78,7 +94,7 @@ def test_spawn_redirect(app, io_loop):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
print(urlparse(r.url))
|
print(urlparse(r.url))
|
||||||
path = urlparse(r.url).path
|
path = urlparse(r.url).path
|
||||||
assert path == '/user/%s' % name
|
assert path == ujoin(app.base_url, 'user/%s' % name)
|
||||||
|
|
||||||
# should have started server
|
# should have started server
|
||||||
status = io_loop.run_sync(u.spawner.poll)
|
status = io_loop.run_sync(u.spawner.poll)
|
||||||
@@ -89,7 +105,7 @@ def test_spawn_redirect(app, io_loop):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
print(urlparse(r.url))
|
print(urlparse(r.url))
|
||||||
path = urlparse(r.url).path
|
path = urlparse(r.url).path
|
||||||
assert path == '/user/%s' % name
|
assert path == ujoin(app.base_url, '/user/%s' % name)
|
||||||
|
|
||||||
def test_spawn_page(app):
|
def test_spawn_page(app):
|
||||||
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
|
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):
|
def test_spawn_form(app, io_loop):
|
||||||
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
|
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')
|
cookies = app.login_user('jones')
|
||||||
orm_u = orm.User.find(app.db, 'jones')
|
orm_u = orm.User.find(app.db, 'jones')
|
||||||
u = app.users[orm_u]
|
u = app.users[orm_u]
|
||||||
@@ -121,7 +137,7 @@ def test_spawn_form(app, io_loop):
|
|||||||
|
|
||||||
def test_spawn_form_with_file(app, io_loop):
|
def test_spawn_form_with_file(app, io_loop):
|
||||||
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
|
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')
|
cookies = app.login_user('jones')
|
||||||
orm_u = orm.User.find(app.db, 'jones')
|
orm_u = orm.User.find(app.db, 'jones')
|
||||||
u = app.users[orm_u]
|
u = app.users[orm_u]
|
||||||
@@ -136,8 +152,6 @@ def test_spawn_form_with_file(app, io_loop):
|
|||||||
files={'hello': ('hello.txt', b'hello world\n')}
|
files={'hello': ('hello.txt', b'hello world\n')}
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
print(u.spawner)
|
|
||||||
print(u.spawner.user_options)
|
|
||||||
assert u.spawner.user_options == {
|
assert u.spawner.user_options == {
|
||||||
'energy': '511keV',
|
'energy': '511keV',
|
||||||
'bounds': [-1, 1],
|
'bounds': [-1, 1],
|
||||||
@@ -147,3 +161,121 @@ def test_spawn_form_with_file(app, io_loop):
|
|||||||
'content_type': 'application/unknown'},
|
'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
|
import os
|
||||||
from queue import Queue
|
from queue import Queue
|
||||||
from subprocess import Popen
|
from subprocess import Popen
|
||||||
|
from urllib.parse import urlparse, unquote
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from .mocking import MockHub
|
from .mocking import MockHub
|
||||||
from .test_api import api_request
|
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):
|
def test_external_proxy(request, io_loop):
|
||||||
"""Test a proxy started before the Hub"""
|
"""Test a proxy started before the Hub"""
|
||||||
@@ -34,6 +35,8 @@ def test_external_proxy(request, io_loop):
|
|||||||
'--api-port', str(proxy_port),
|
'--api-port', str(proxy_port),
|
||||||
'--default-target', 'http://%s:%i' % (app.hub_ip, app.hub_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)
|
proxy = Popen(cmd, env=env)
|
||||||
def _cleanup_proxy():
|
def _cleanup_proxy():
|
||||||
if proxy.poll() is None:
|
if proxy.poll() is None:
|
||||||
@@ -60,7 +63,11 @@ def test_external_proxy(request, io_loop):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
routes = io_loop.run_sync(app.proxy.get_routes)
|
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
|
# teardown the proxy and start a new one in the same place
|
||||||
proxy.terminate()
|
proxy.terminate()
|
||||||
@@ -76,7 +83,7 @@ def test_external_proxy(request, io_loop):
|
|||||||
|
|
||||||
# check that the routes are correct
|
# check that the routes are correct
|
||||||
routes = io_loop.run_sync(app.proxy.get_routes)
|
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
|
# teardown the proxy again, and start a new one with different auth and port
|
||||||
proxy.terminate()
|
proxy.terminate()
|
||||||
@@ -90,13 +97,16 @@ def test_external_proxy(request, io_loop):
|
|||||||
'--api-port', str(proxy_port),
|
'--api-port', str(proxy_port),
|
||||||
'--default-target', 'http://%s:%i' % (app.hub_ip, app.hub_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)
|
proxy = Popen(cmd, env=env)
|
||||||
wait_for_proxy()
|
wait_for_proxy()
|
||||||
|
|
||||||
# tell the hub where the new proxy is
|
# tell the hub where the new proxy is
|
||||||
r = api_request(app, 'proxy', method='patch', data=json.dumps({
|
r = api_request(app, 'proxy', method='patch', data=json.dumps({
|
||||||
'port': proxy_port,
|
'port': proxy_port,
|
||||||
|
'protocol': 'http',
|
||||||
|
'ip': app.ip,
|
||||||
'auth_token': new_auth_token,
|
'auth_token': new_auth_token,
|
||||||
}))
|
}))
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
@@ -113,7 +123,8 @@ def test_external_proxy(request, io_loop):
|
|||||||
|
|
||||||
# check that the routes are correct
|
# check that the routes are correct
|
||||||
routes = io_loop.run_sync(app.proxy.get_routes)
|
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):
|
def test_check_routes(app, io_loop):
|
||||||
proxy = app.proxy
|
proxy = app.proxy
|
||||||
@@ -123,13 +134,24 @@ def test_check_routes(app, io_loop):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
zoe = orm.User.find(app.db, 'zoe')
|
zoe = orm.User.find(app.db, 'zoe')
|
||||||
assert zoe is not None
|
assert zoe is not None
|
||||||
|
zoe = app.users[zoe]
|
||||||
before = sorted(io_loop.run_sync(app.proxy.get_routes))
|
before = sorted(io_loop.run_sync(app.proxy.get_routes))
|
||||||
assert '/user/zoe' in before
|
assert unquote(zoe.proxy_path) in before
|
||||||
io_loop.run_sync(app.proxy.check_routes)
|
io_loop.run_sync(lambda : app.proxy.check_routes(app.users, app._service_map))
|
||||||
io_loop.run_sync(lambda : proxy.delete_user(zoe))
|
io_loop.run_sync(lambda : proxy.delete_user(zoe))
|
||||||
during = sorted(io_loop.run_sync(app.proxy.get_routes))
|
during = sorted(io_loop.run_sync(app.proxy.get_routes))
|
||||||
assert '/user/zoe' not in during
|
assert unquote(zoe.proxy_path) not in during
|
||||||
io_loop.run_sync(app.proxy.check_routes)
|
io_loop.run_sync(lambda : app.proxy.check_routes(app.users, app._service_map))
|
||||||
after = sorted(io_loop.run_sync(app.proxy.get_routes))
|
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
|
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]
|
||||||
|
|
||||||
|
|
||||||
229
jupyterhub/tests/test_services_auth.py
Normal file
229
jupyterhub/tests/test_services_auth.py
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
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',
|
||||||
|
'groups': ['lions'],
|
||||||
|
}
|
||||||
|
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']
|
||||||
|
|
||||||
|
# pass group whitelist
|
||||||
|
TestHandler.hub_groups = {'lions'}
|
||||||
|
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 group whitelist
|
||||||
|
TestHandler.hub_groups = {'tigers'}
|
||||||
|
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
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user