Compare commits

...

393 Commits
1.1.0 ... 1.2.2

Author SHA1 Message Date
Min RK
69bb34b943 release 1.2.2 2020-11-27 14:44:42 +01:00
Min RK
728fbc68e0 Merge pull request #3285 from meeseeksmachine/auto-backport-of-pr-3284-on-1.2.x
Backport PR #3284 on branch 1.2.x (Changelog for 1.2.2)
2020-11-27 14:41:45 +01:00
Min RK
0dad9a3f39 Backport PR #3284: Changelog for 1.2.2 2020-11-27 13:41:32 +00:00
Min RK
41f291c0c9 Merge pull request #3282 from meeseeksmachine/auto-backport-of-pr-3257-on-1.2.x
Backport PR #3257 on branch 1.2.x (Update services-basics.md to use jupyterhub_idle_culler)
2020-11-27 10:05:01 +01:00
Min RK
9a5b11d5e1 Merge pull request #3283 from meeseeksmachine/auto-backport-of-pr-3250-on-1.2.x
Backport PR #3250 on branch 1.2.x (remove push-branch conditions for CI)
2020-11-27 10:04:11 +01:00
Erik Sundell
b47159b31e Backport PR #3250: remove push-branch conditions for CI 2020-11-27 09:03:54 +00:00
Erik Sundell
bbe377b70a Backport PR #3257: Update services-basics.md to use jupyterhub_idle_culler 2020-11-27 08:59:11 +00:00
Min RK
374a3a7b36 Merge pull request #3273 from meeseeksmachine/auto-backport-of-pr-3237-on-1.2.x
Backport PR #3237 on branch 1.2.x ([proxy.py] Improve robustness when detecting and closing existing proxy processes)
2020-11-26 10:01:46 +01:00
Min RK
32c493e5ab Merge pull request #3272 from meeseeksmachine/auto-backport-of-pr-3252-on-1.2.x
Backport PR #3252 on branch 1.2.x (Standardize "Sign in" capitalization on the login page)
2020-11-20 10:34:41 +01:00
Min RK
edfd363758 Merge pull request #3271 from meeseeksmachine/auto-backport-of-pr-3265-on-1.2.x
Backport PR #3265 on branch 1.2.x (Fix RootHandler when default_url is a callable)
2020-11-20 10:34:31 +01:00
Min RK
d72a5ca3e4 Merge pull request #3274 from meeseeksmachine/auto-backport-of-pr-3255-on-1.2.x
Backport PR #3255 on branch 1.2.x (Environment marker on pamela)
2020-11-20 10:34:22 +01:00
Min RK
3a6309a570 Backport PR #3255: Environment marker on pamela 2020-11-20 09:17:45 +00:00
Min RK
588407200f Backport PR #3237: [proxy.py] Improve robustness when detecting and closing existing proxy processes 2020-11-20 09:17:08 +00:00
Min RK
5cc36a6809 Backport PR #3252: Standardize "Sign in" capitalization on the login page 2020-11-20 09:16:57 +00:00
Min RK
5733eb76c2 Backport PR #3265: Fix RootHandler when default_url is a callable 2020-11-20 09:15:46 +00:00
Min RK
d9719e3538 Merge pull request #3269 from meeseeksmachine/auto-backport-of-pr-3261-on-1.2.x
Backport PR #3261 on branch 1.2.x (Only preserve params when ?next= is unspecified)
2020-11-20 10:10:43 +01:00
Min RK
7c91fbea93 Merge pull request #3270 from meeseeksmachine/auto-backport-of-pr-3246-on-1.2.x
Backport PR #3246 on branch 1.2.x (Migrate from travis to GitHub actions)
2020-11-20 10:10:30 +01:00
Min RK
5076745085 back to dev 2020-11-20 09:54:14 +01:00
Min RK
39eea2f053 Backport PR #3246: Migrate from travis to GitHub actions 2020-11-20 08:51:59 +00:00
Min RK
998f5d7b6c Backport PR #3261: Only preserve params when ?next= is unspecified 2020-11-20 08:48:02 +00:00
Min RK
fc6cd33ce0 release 1.2.1 2020-10-30 13:20:43 +01:00
Erik Sundell
b0b8e2d058 Merge pull request #3235 from minrk/changelog-1.2.1
Changelog for 1.2.1
2020-10-30 13:19:52 +01:00
Erik Sundell
6bfa402bfa Apply suggestions from code review 2020-10-30 13:19:18 +01:00
Min RK
b51a0bba92 Changelog for 1.2.1 2020-10-30 13:15:19 +01:00
Erik Sundell
2d3f962a1d Merge pull request #3234 from gesiscss/master
Make external JupyterHub services' oauth_no_confirm configuration work as intentend
2020-10-30 13:07:39 +01:00
Kenan Erdogan
625242136a fix checking if oauth confirm is needed 2020-10-30 10:39:02 +01:00
Min RK
f92560fed0 back to dev 2020-10-29 14:06:20 +01:00
Min RK
8249ef69f0 release jupyterhub 1.2.0 2020-10-29 14:03:34 +01:00
Min RK
c63605425f Merge pull request #3233 from minrk/1.2.0-final
latest changelog since 1.2.0b1
2020-10-29 14:03:01 +01:00
Min RK
5b57900c0b 1.2.0 heading in changelog
Co-authored-by: Erik Sundell <erik.i.sundell@gmail.com>
2020-10-29 14:02:35 +01:00
Erik Sundell
d0afdabd4c order changelog entries systematically 2020-10-29 13:13:02 +01:00
Min RK
618746fa00 latest changelog since 1.2.0b1 2020-10-29 13:02:04 +01:00
Min RK
e7bc6c2ba9 Merge pull request #3229 from minrk/configurable-pagination
make pagination configurable
2020-10-29 10:53:29 +01:00
Min RK
e9f86cd602 make pagination configurable
add some unittests for pagination

reorganize pagination a bit to make it easier to configure
2020-10-29 09:24:34 +01:00
Erik Sundell
6e8517f795 Merge pull request #3232 from consideRatio/pr/travis-badge
Update travis-ci badge in README.md
2020-10-28 23:01:04 +01:00
Erik Sundell
5fa540bea1 Update travis-ci badge in README.md 2020-10-28 22:59:44 +01:00
Min RK
99f597887c Merge pull request #3223 from consideRatio/pr/proxy-api_request-retries
Make api_request to CHP's REST API more reliable
2020-10-28 15:21:23 +01:00
Erik Sundell
352526c36a Merge pull request #3226 from xlotlu/patch-1
Fix typo in documentation
2020-10-28 08:09:11 +01:00
Ionuț Ciocîrlan
cbbed04eed fix typo 2020-10-28 03:00:31 +02:00
Erik Sundell
b2756fb18c Retry on >=500 errors on hub to proxy REST API reqeusts 2020-10-27 16:53:53 +01:00
Erik Sundell
37b88029e4 Revert improved logging attempt 2020-10-27 16:28:56 +01:00
Erik Sundell
4b7413184e Adjust hub to proxy REST API requests' timeouts 2020-10-27 16:23:40 +01:00
Min RK
41ef0da180 Merge pull request #3219 from elgalu/patch-3
Fix #2284 must be sent from authorization page
2020-10-27 15:41:05 +01:00
Erik Sundell
a4a8b3fa2c Fix scope mistake 2020-10-27 13:38:34 +01:00
Erik Sundell
02e5984f34 Let API requests to CHP retry on 429,500,503,504 as well 2020-10-27 12:52:14 +01:00
Erik Sundell
b91c5a489c Rely on HTTPError over pycurl assumed CurlError 2020-10-26 20:39:20 +01:00
Erik Sundell
c47c3b2f9e Make api_request to CHP's REST API more reliable 2020-10-25 02:35:36 +01:00
Min RK
eaa1353dcd typos in use of partition 2020-10-23 14:16:46 +02:00
Leo Gallucci
b9a3b0a66a Fix #2284 must be sent from authorization pageUpdate jupyterhub/apihandlers/auth.py
Co-authored-by: Min RK <benjaminrk@gmail.com>
2020-10-22 11:36:15 +02:00
Leo Gallucci
929b805fae Fix #2284 must be sent from authorization page
Fix #2284 Authorization form must be sent from authorization page
2020-10-21 17:57:14 +02:00
Min RK
082f6516a1 1.2.0b1 2020-10-16 10:14:32 +02:00
Erik Sundell
1aa21f1d6c Merge pull request #3192 from consideRatio/pr/changelog-for-1.2.0b1
changelog for 1.2.0b1
2020-10-15 15:30:30 +02:00
Erik Sundell
cec9702796 changelog for 1.2.0b1 updated 2020-10-15 14:56:43 +02:00
Erik Sundell
f8cbda9c3c Merge pull request #3208 from minrk/traitlets-list-allow-none
avoid specifying default_value=None in Command traits
2020-10-15 14:47:36 +02:00
Min RK
71aee05bc0 use /api/status to test server
workaround 404 issue with /api/spec.yaml in jupyter-server 1.0.4
2020-10-15 13:23:02 +02:00
Erik Sundell
772de55a0d Merge pull request #3209 from minrk/rtd-docs
stop building docs on circleci
2020-10-15 12:14:40 +02:00
Min RK
e6f92238b1 stop building docs on circleci
RTD CI is enabled now
2020-10-15 11:41:11 +02:00
Min RK
db76b52e35 avoid specifying default_value=None in Command traits
causes issues with traitlets dev where 'unspecified' should be Undefined, not specified-None

Best to leave it out if it's really unspecified
2020-10-15 11:38:08 +02:00
Min RK
e6e994e843 add changelog highlights for 1.2.0 2020-10-15 11:01:26 +02:00
Min RK
284e379341 Merge pull request #3204 from kreuzert/exponential_backoff_overflow_exception
Prevent OverflowErrors in exponential_backoff()
2020-10-15 10:39:28 +02:00
Erik Sundell
3ce1cc63af Merge pull request #3207 from kinow/patch-2
[docs] Remove duplicate line in changelog for 1.1.0
2020-10-15 00:34:56 +02:00
Bruno P. Kinoshita
9945a7f7be Update changelog.md
Remove duplicate changelog from 1.1.0
2020-10-15 09:59:04 +13:00
Tim Kreuzer
004c964cc1 Update utils.py 2020-10-13 10:37:31 +02:00
Tim Kreuzer
0f0d6d12d3 Update jupyterhub/utils.py
Co-authored-by: Erik Sundell <erik.i.sundell@gmail.com>
2020-10-13 10:30:05 +02:00
Tim Kreuzer
c97e4d4e2f Update utils.py
Prevent exponential_backoff() to crash with an Vverflow Error
2020-10-12 17:25:25 +02:00
Erik Sundell
53d496aff5 changelog for 1.2.0b1 2020-10-04 07:04:42 +02:00
Min RK
032ae29066 Merge pull request #3184 from rainwoodman/patch-1
Mention the PAM pitfall on fedora.
2020-10-02 10:50:17 +02:00
Yu Feng
21caa57e7b remove sshauthenticator reference. 2020-10-01 09:13:37 -07:00
Yu Feng
37ee104afa Update docs/source/reference/config-sudo.md
Co-authored-by: Erik Sundell <erik.i.sundell@gmail.com>
2020-10-01 09:11:15 -07:00
Erik Sundell
dac75ff996 Merge pull request #3019 from stv0g/remove-unused-imports
Remove unused imports
2020-10-01 13:17:36 +02:00
Erik Sundell
67e06e5a18 Fix order of imports 2020-10-01 12:44:51 +02:00
Erik Sundell
4cbc0bad34 Merge branch 'master' into remove-unused-imports 2020-10-01 12:07:37 +02:00
Erik Sundell
9f8c1decc4 Merge pull request #2891 from rajat404/auto-gen-docs
Generate prometheus metrics docs
2020-10-01 11:40:05 +02:00
Erik Sundell
1244533387 Merge pull request #3185 from rainwoodman/patch-2
Add SELinux configuration for nginx
2020-10-01 11:15:32 +02:00
Erik Sundell
8c30724f17 monitoring docs: fixes following monitoring section relocation 2020-10-01 10:45:11 +02:00
Erik Sundell
50868f5bb5 monitoring docs: relocate monitoring section under technical reference 2020-10-01 10:36:19 +02:00
Erik Sundell
e15b6ad52e Makefile: let make html depend on generated metrics.rst 2020-10-01 10:13:31 +02:00
Rajat Goyal
b194135a0f Generate list of prometheus metrics in reStructuredText rather than markdown 2020-09-30 23:52:29 +05:30
Rajat Goyal
5b8a7fd191 Remove unused dependency 2020-09-30 23:25:22 +05:30
Rajat Goyal
be272ffb2a Formatted text for better readability 2020-09-30 23:14:21 +05:30
Rajat Goyal
8ee60ce0c7 Add metrics documentation generation step in CircleCI & RTD configs
Also rename generated metrics documentation directory `_gen` from `gen`
2020-09-30 22:57:46 +05:30
Rajat Goyal
e553bcb7e2 Unpin dependencies from their patch versions 2020-09-30 22:08:50 +05:30
Rajat Goyal
c0288ec6f6 Update docs/source/monitoring/index.rst
- Fixes typo (eolving -> evolving)
- re-use the word current instead of momentary for comprehensibility
- references JupyterHubs current state with its instead of the for comprehensibility

Co-authored-by: Erik Sundell <erik.i.sundell@gmail.com>
2020-09-30 22:08:50 +05:30
Rajat Goyal
65b83f5f00 Update docs/source/monitoring/index.rst
Co-authored-by: Erik Sundell <erik.i.sundell@gmail.com>
2020-09-30 22:08:50 +05:30
Rajat Goyal
dcd520179c Made changes in monitoring docs as per the feedback on PR review 2020-09-30 22:08:50 +05:30
Rajat Goyal
c830d964d5 Apply suggestions from code review
Co-authored-by: Min RK <benjaminrk@gmail.com>
2020-09-30 22:08:50 +05:30
rajat404
9e5993f1da Docs: Fix typo; Add generate task as sub-task in html 2020-09-30 22:08:50 +05:30
rajat404
7ed3e0506b Extract doc generation logic in separate method 2020-09-30 22:08:50 +05:30
rajat404
7045e1116c Inspect metrics and generate metric list in docs; Add monitoring section in Docs 2020-09-30 22:08:50 +05:30
Yu Feng
fb56fd406f Add SELinux configuration for nginx
On a Fedora workstation these steps are needed.
2020-09-22 22:08:42 -07:00
Yu Feng
5489395272 Mention the PAM pitfall on fedora. 2020-09-22 21:51:08 -07:00
Yuvi Panda
6ecda96dd6 Merge pull request #3174 from AngelOnFira/upgrade-jquery-dep
Upgraded Jquery dep
2020-09-17 22:42:26 +05:30
Forest Anderson
80ad455fc7 Upgraded jquery dep 2020-09-14 13:01:27 -04:00
Min RK
21eaf0dd9f Merge pull request #3077 from kinow/add-config-reference
Add Configuration Reference section to docs
2020-09-08 16:40:10 +02:00
Min RK
84d2524025 jupyterhub_config.py filename typo 2020-09-08 16:39:51 +02:00
Min RK
959dfb145a Merge pull request #3121 from rkdarst/clear-state-after-post-stop-hook
jupyterhub/user: clear spawner state after post_stop_hook
2020-09-08 16:38:18 +02:00
Min RK
998c18df42 Merge pull request #3133 from ideonate/master
Allow JupyterHub.default_url to be a callable
2020-09-08 16:36:52 +02:00
Richard Darst
88b10aa2f5 jupyterhub/user: Remember to save the state in the database 2020-09-08 13:48:27 +03:00
Dan Lester
d8f5758e08 Fix rst in default_url docstring 2020-09-08 09:55:03 +01:00
Min RK
47e45a4d3f Merge pull request #3136 from pabepadu/add_footer_block
Add a footer block + wrap the admin footer in this block
2020-09-08 09:38:21 +02:00
Min RK
3e31ff4ac7 Merge pull request #3160 from rcthomas/control-service-display
Control service display
2020-09-08 09:37:12 +02:00
Min RK
ff30396a8e Merge pull request #3028 from possiblyMikeB/ui-feedback-onsubmit
UI Feedback on Submit
2020-09-08 09:36:39 +02:00
Min RK
196a7fbc65 Merge pull request #3072 from minrk/purge-expired
synchronize implementation of expiring values
2020-09-08 09:35:25 +02:00
Richard Darst
c66e8bb4c9 jupyterhub/user: remuve extraneous = {}
- Thanks to review from @minrk
2020-09-07 17:21:23 +03:00
Min RK
5595146fe2 Merge pull request #3147 from jgwerner/fix/api-request-error
Get error description from error key vs error_description key
2020-09-07 16:18:21 +02:00
Min RK
76b688e574 Merge pull request #3137 from lydian/sort_on_spawner_last_activity
admin page sorts on spawner last_activity instead of user last_activity
2020-09-07 16:14:12 +02:00
Min RK
f00d0be4d6 Merge pull request #3156 from manics/docker-py38
Update Dockerfile to ubuntu:focal (Python 3.8)
2020-09-07 16:13:18 +02:00
Min RK
f9d815676f verify static files in docker tests 2020-09-07 16:06:48 +02:00
Min RK
94612d09a6 build wheel with setup.py bdist_wheel
pip wheel from scratch may not include files generated during build
2020-09-07 15:03:13 +02:00
Dan Lester
76ed65ed82 default_url takes handler object instead of user 2020-08-31 18:36:57 +01:00
Greg
560bab395b update based on pr suggestion
Signed-off-by: Greg <werner.greg@gmail.com>
2020-08-27 11:16:57 -04:00
Greg
c68b846eef get error key or error_description key if not available
Signed-off-by: Greg <werner.greg@gmail.com>
2020-08-27 11:12:18 -04:00
Greg
5896b2c9f7 get error description from error key vs error_description key
Signed-off-by: Greg <werner.greg@gmail.com>
2020-08-27 11:12:18 -04:00
Min RK
0317fd63fa Merge pull request #3103 from kinow/responsive-issues
Hide hamburger button menu in mobile/responsive mode and fix other minor issues
2020-08-27 11:15:50 +02:00
Min RK
7f6886c60f Merge pull request #3104 from cmd-ntrf/rest-api-version
Update version in docs/rest-api.yaml
2020-08-27 11:00:14 +02:00
Min RK
10bdca8901 Merge pull request #3142 from snickell/document-external-service-api-tokens-better
Document external service api_tokens better
2020-08-27 09:52:31 +02:00
Min RK
66cb2c0f3e Merge pull request #3128 from minrk/mix-it-in
Implement singleuser with mixins
2020-08-27 09:51:19 +02:00
Min RK
0152e29946 Merge pull request #3159 from synchronizing/patch-1
Added extra documentation for endpoint /users/{name}/servers/{server_name}.
2020-08-27 09:51:01 +02:00
Min RK
c6f0c07931 Merge pull request #3157 from manics/python-traitlets-latest
Don't allow 'python:3.8 + master dependencies' to fail
2020-08-27 09:45:50 +02:00
Min RK
51ceab9f6f Merge pull request #3149 from betatim/simplifiy-health-checks
Simplify code of the health check handler
2020-08-27 09:44:02 +02:00
Rollin Thomas
46ead8cd9d Add display variable to tests 2020-08-26 21:43:16 -07:00
Rollin Thomas
bfb3d50936 Reformat! 2020-08-26 21:29:28 -07:00
Rollin Thomas
962307475e Add service display to service API model 2020-08-26 19:15:21 -07:00
Rollin Thomas
80f4edcd20 Omit service if it is not OK to display 2020-08-26 18:57:17 -07:00
Rollin Thomas
1ad4035943 Control whether service is listed in UI or not 2020-08-26 18:56:03 -07:00
Felipe Faria
5ab735fea3 Added extra documentation for endpoint /users/{name}/servers/{server_name}. 2020-08-26 19:07:57 -04:00
Simon Li
e79cb0d376 Don't allow 'python:3.8 + master dependencies' to fail 2020-08-26 22:40:57 +01:00
Simon Li
f728cf89c6 Update Dockerfile to ubuntu:focal (Python 3.8) 2020-08-26 22:24:14 +01:00
Tim Head
8f719e21d2 Simplify code of the health check handler 2020-08-26 14:07:30 +02:00
Min RK
29de00ee3c Merge pull request #3140 from chancez/fix_ssl_http_client_master
jupyterhub/utils: Load system default CA certificates in make_ssl_context
2020-08-26 14:05:29 +02:00
Chance Zibolski
52291b0012 jupyterhub/utils: Load system default CA certificates in make_ssl_context
Fixes issues with OAuth flows when internal_ssl is enabled.
When internal_ssl was enabled requests to non-internal endpoints failed
because the system CAs were not being loaded.

This caused failures with public OAuth providers with public CAs since
they would fail to validate.
2020-08-25 09:09:58 -07:00
Georgiana Elena
e58c341290 Merge pull request #3150 from yhal-nesi/master
update prometheus metrics for server spawn when it fails with exception
2020-08-22 00:01:53 +03:00
yhal-nesi
f988a4939e Update jupyterhub/handlers/base.py
Ah makes sense, I was wandering why the tests fail.

Co-authored-by: Georgiana Elena <georgiana.dolocan@gmail.com>
2020-08-22 08:47:15 +12:00
Yuriy Halytskyy
60ee2bfc35 update prometheus metrics for server spawn when it fails with exception 2020-08-20 08:18:39 +12:00
Erik Sundell
42601c52cc Merge pull request #3151 from consideRatio/docs/move-cert-docstring
docs: please docs linter (move_cert docstring)
2020-08-19 14:10:54 +02:00
Erik Sundell
0679586b2c docs: please docs linter properly
We are users of the napoleon sphinx extension, which helps us parse our
Google Style Python Docstrings, and its syntax suggest we should use
indentation when we use more then one string for an entry in an
Arguments: or Returns: list.

For more details, see: https://github.com/jupyterhub/jupyterhub/pull/3151#issuecomment-676186565
2020-08-19 13:49:28 +02:00
Erik Sundell
be4201f7ee docs: please docs linter (move_cert docstring) 2020-08-19 13:14:46 +02:00
Min RK
11a73b5630 Merge pull request #3131 from rkevin-arch/healthcheck-head-request
Allow head requests for the health endpoint
2020-08-18 10:57:09 +02:00
Tim Head
f1efac41bf Merge pull request #3143 from basvandervlies/apache_reverse_proxy_doc
Needed NoEsacpe (NE)  option for apache
2020-08-14 14:54:23 +02:00
Bas van der Vlies
aa6921dd5a Needed NoEsacpe (NE) option for apache
Else %20 is esacped to %25%20 and we acan not rename "Untitled Folder'
or opening files with spaces or other special chars fails.
2020-08-14 11:24:27 +02:00
Seth Nickell
e94da17c3c Document external service api_tokens better
- Explicitly mention min-8-char constraint
- Connect the api_token in the configuration with the one mentioned in auth requests

Co-authored-by: Mike Situ <msitu@ceresimaging.net>
2020-08-13 12:28:17 -10:00
Min RK
e2ee18fa86 Merge pull request #3123 from alexweav/tornado-py38
app.py: Work around incompatibility between Tornado 6 and asyncio proactor event loop in python 3.8 on Windows
2020-08-10 09:18:24 +02:00
Lydian Lee
c5ec8ceba3 admin page sorts on spawner last_activity instead of user last_activity 2020-08-07 16:37:47 -07:00
pabepadu
3458c742cb Add a footer block + wrap the admin footer in this block 2020-08-07 02:19:21 +02:00
Georgiana Elena
d1a85e53dc Merge pull request #3132 from pabepadu/fix_services_dropdown_in_admin_page
Fix the services dropdown on the admin page
2020-08-07 00:13:37 +03:00
Dan Lester
d915cc3ff2 Allow JupyterHub.default_url to be a callable based on user 2020-08-05 11:59:25 +01:00
Georgiana Elena
b11c02c6e0 Merge pull request #3118 from minrk/tag-from-singleuser
only build tagged versions on docker tags
2020-08-05 12:44:23 +03:00
pabepadu
49f3bb53f4 Fix the services dropdown in the admin page 2020-08-05 05:29:21 +02:00
rkevin
9b7a94046b Allow head requests for the health endpoint 2020-08-03 00:20:17 -07:00
Min RK
62ef5ca2fe test with /api/spec.yaml
because /api/status is currently broken in jupyter_server
2020-07-31 12:44:42 +02:00
Min RK
028e0b0b77 include JUPYTERHUB_SINGLEUSER_APP in env_keep
since the child process is the one that inherits it anyway
2020-07-31 12:12:38 +02:00
Min RK
d2a42a69b0 simplify app mixin
get handler classes from instance attributes, rather than arguments

simplifies API
2020-07-31 12:12:11 +02:00
Min RK
1f21f283df Merge pull request #3127 from mriedem/3126-slow-spawn-timeout-warning
Don't log a warning when slow_spawn_timeout is disabled
2020-07-31 12:07:38 +02:00
Alex Weaver
7f35158575 Also apply patch before creating new event loop in atexit, just in case 2020-07-29 11:03:05 -05:00
Min RK
d0da677813 infer default mixins from $JUPYTERHUB_SINGLEUSER_APP
set to e.g. JUPYTERHUB_SINGLEUSER_APP=jupyterlab.labapp.LabApp for JupyterLab
2020-07-24 13:06:35 +02:00
Min RK
a0a02688c5 create singleuser app with mixins
for easier reuse with jupyter_server

mixins have a lot of assumptions about the NotebookApp structure.
Need to make sure these are met by jupyter_server (that's what tests are for!)
2020-07-24 12:57:05 +02:00
Min RK
2372842b8a Merge remote-tracking branch 'origin/master' into mix-it-in
# Conflicts:
#	.travis.yml
2020-07-24 09:53:02 +02:00
Matt Riedemann
7e205a9751 Don't log a warning when slow_spawn_timeout is disabled
When using the `KubeSpawner` it is typical to disable the
`slow_spawn_timeout` by setting it to 0. `zero-to-jupyterhub-k8s`
does this by default [1]. However, this causes an immediate `TimeoutError`
which gets logged as a warning like this:

>User hub-stress-test-123 is slow to start (timeout=0)

This avoids the warning by checking the value and if disabled simply
returns without logging the warning.

[1] https://github.com/jupyterhub/zero-to-jupyterhub-k8s/commit/b4738edc5

Closes #3126
2020-07-23 16:09:19 -05:00
Alex Weaver
e7fab5c304 Format and lint 2020-07-22 15:16:11 -05:00
Alex Weaver
8b8b512d06 Apply asyncio patch 2020-07-22 15:04:16 -05:00
Richard Darst
714072dbd8 jupyterhub/user: clear spawner state after post_stop_hook
- Related issue: #3120.  Closes: #3120.

- I realized that spawner.clear_state() is called before
  spawner.post_stop_hook().  This caused was a bit surprising to me,
  and caused some issues.

- I tried the naive strategy of moving clear_state to later and
  setting the orm_state to `{}` at the point where it used to be
  clear.

- This tries to maintain the exception behavior of clear_state and
  post_stop_hook, but is exactly identical.

- To review:

  - I'm not sure this is a good idea!

  - Carefully consider the implications of this.  I am not at all sure
    about unintended side-effects or what intended semantics are.
2020-07-22 10:06:21 +03:00
Min RK
6e8f39c22d only build tagged versions on docker tags
instead of building 'stable' from master
2020-07-20 10:14:35 +02:00
Erik Sundell
f3c3225124 Merge pull request #3114 from yuvipanda/no-cull-idle
Remove idle culler example
2020-07-14 17:03:12 +02:00
Georgiana Elena
614bfe77d8 Update examples/cull-idle/README.md 2020-07-14 14:22:51 +03:00
YuviPanda
1beea06ce5 Remove idle culler example
Has been moved to its own repo.

See https://github.com/jupyterhub/the-littlest-jupyterhub/pull/559
for more info
2020-07-12 17:14:14 +05:30
Erik Sundell
42adb44153 Merge pull request #3111 from mriedem/log-slow-stop-timeout
Log slow_stop_timeout when hit like slow_spawn_timeout
2020-07-11 02:56:13 +02:00
Matt Riedemann
d5a0202106 Log slow_stop_timeout when hit like slow_spawn_timeout
When `slow_spawn_timeout` is hit the configured timeout value
gets logged [1]. This does the same thing when `slow_stop_timeout`
is hit.

[1] https://github.com/jupyterhub/jupyterhub/blob/1.1.0/jupyterhub/handlers/base.py#L947
2020-07-10 11:38:26 -05:00
Georgiana Elena
3d524f2092 Merge pull request #3109 from kxiao-fn/proper_named_server_deletion
fix for stopping named server deleting default server and tests
2020-07-07 15:41:43 +03:00
Katherine Xiao
409835303e formatting 2020-07-06 17:45:08 -07:00
Katherine Xiao
acc8d15fec fixed test 2020-07-06 17:23:42 -07:00
Katherine Xiao
608cad6404 fix in base.py 2020-07-06 12:53:50 -07:00
Katherine Xiao
571a428375 fix deletion of default server when stopping named server and added corresponding test 2020-07-06 12:48:41 -07:00
Chris Holdgraf
1575adf272 Merge pull request #3107 from consideRatio/docs-logo-rem-unused-stuff
docs: unsqueeze logo, remove unused CSS and templates
2020-07-06 08:00:14 -07:00
Erik Sundell
4bc6d869f3 docs: unsqueeze logo, remove unused CSS and templates 2020-07-05 03:12:18 +02:00
Min RK
e5a6119505 Merge pull request #3090 from minrk/words-matter 2020-07-03 12:27:08 +02:00
Félix-Antoine Fortin
d80dab284d Update version in docs/rest-api.yaml 2020-06-30 08:59:29 -04:00
Bruno P. Kinoshita
9d556728bb Add padding for the span with user name and logout button (responsive mode only) 2020-06-25 23:31:54 +12:00
Bruno P. Kinoshita
4369e2cbfa Adjust jupyterhub logo margin-left in responsive mode 2020-06-25 23:31:54 +12:00
Bruno P. Kinoshita
ef4455bb67 Closes #2182 display hamburger menu only if user variable is present (in responsive mode) 2020-06-25 23:31:54 +12:00
Min RK
76c9111d80 Merge pull request #3089 from kinow/redirect-with-parameters 2020-06-25 11:08:17 +02:00
Bruno P. Kinoshita
946ed844c5 Update jupyterhub/handlers/base.py
Co-authored-by: Min RK <benjaminrk@gmail.com>
2020-06-25 19:41:46 +12:00
Min RK
cceb652039 TODO is TODONE
Co-authored-by: Georgiana Elena <georgiana.dolocan@gmail.com>
2020-06-24 20:19:44 +02:00
Min RK
6e988bf587 call it allowed_users
be clearer since it's users vs groups, etc.
2020-06-24 13:29:42 +02:00
Simon Li
dbc6998375 Merge pull request #3102 from minrk/unpin-telemetry
loosen jupyter-telemetry pin
2020-06-23 14:18:40 +01:00
Bruno P. Kinoshita
1bdc9aa297 Escape/encode parameters with the next URL, add more tests 2020-06-24 00:18:55 +12:00
Bruno P. Kinoshita
73f1211286 Update append_query_parameters to have exclude=["none"] by default,
and avoid using dicts with url_concat, to have consistent tests
as otherwise in Python 3.5 the generated URL's could have parameters
in random order.
2020-06-23 22:06:57 +12:00
Min RK
3fece09dda loosen jupyter-telemetry pin
we don't want strict pinning in package dependencies
2020-06-23 10:13:31 +02:00
Min RK
7ad4b0c7cb update allowed/blocked language in docs
our words matter, let's be more mindful
2020-06-23 10:10:07 +02:00
Min RK
252015f50d Merge pull request #3071 from minrk/userdict-get 2020-06-23 10:03:13 +02:00
Min RK
b3cc235c8a Merge pull request #3087 from fcollonval/patch-1 2020-06-23 10:02:34 +02:00
Min RK
47d7af8f48 Merge pull request #3100 from mriedem/remove-old-print 2020-06-23 09:58:00 +02:00
Matt Riedemann
8528684dc4 Remove old context-less print statement
This was added in PR #2721 and by default results in just printing
out "10" without any context when starting the hub service. This
simply removes the orphan print statement.

I'm open to changing this to a debug log statement with context if
someone finds that useful, e.g.:

`self.log.debug('Effective init_spawners_timeout: %s', init_spawners_timeout)`
2020-06-22 15:35:15 -05:00
Bruno P. Kinoshita
d4ce3aa731 Add unit tests 2020-06-20 22:51:16 +12:00
Min RK
ec710f4d90 test subclass priority when overriding old methods 2020-06-18 11:50:44 +02:00
Bruno P. Kinoshita
14378f4cc2 Include the query string parameters when redirecting to a new URL 2020-06-17 22:37:20 +12:00
Min RK
cc8e780653 rename white/blacklist allowed/blocked
- group_whitelist -> allowed_groups

still todo: handle deprecated signatures in check_whitelist methods while preserving subclass overrides
2020-06-15 14:40:44 +02:00
Frédéric Collonval
5bbf584cb7 Make delete_invalid_users configurable 2020-06-13 15:58:46 +02:00
Erik Sundell
b5defabf49 Merge pull request #3086 from manics/sshspawner
Replace zonca/remotespawner with NERSC/sshspawner
2020-06-13 14:05:05 +02:00
Simon Li
2d1f91e527 Replace zonca/remotespawner with NERSC/sshspawner
https://github.com/zonca/remotespawner is archived, the readme recommends https://github.com/jupyterhub/batchspawner
2020-06-13 11:47:34 +01:00
Tim Head
1653ee77ed Merge pull request #3084 from elgalu/patch-2
Remove already done named servers from roadmap
2020-06-13 09:55:26 +02:00
Leo Gallucci
10f09f4f70 Remove already done named servers from roadmap
Remove already done "UI for managing named servers" from the roadmap
2020-06-12 18:00:00 +02:00
Min RK
b7f277147b Merge pull request #3057 from GeorgianaElena/add_config_warn 2020-06-12 17:21:26 +02:00
Min RK
f3be735eeb Merge pull request #3082 from ChameleonCloud/fix-missing-static-files 2020-06-12 17:19:35 +02:00
Georgiana Elena
3e855eb1be Merge pull request #3083 from minrk/docker-demo-build
build jupyterhub/jupyterhub-demo image on docker hub
2020-06-12 12:10:27 +03:00
Min RK
98dc1f71db build jupyterhub/jupyterhub-demo image on docker hub 2020-06-12 10:03:34 +02:00
Jason Anderson
703703a648 Ensure client dependencies build before wheel
Bug #2852 describes an issue where templates cannot be found by
JupyterHub when using the Docker images built out of this repo. The
issue turned out to be due to missing node_modules at the time of build.

There is a hook in the `package.json` that causes node_modules to be
copied to the static/components directory post-install. If this is not
run, those components are not in the static directory and thus are not
included in the wheel when it is built.

Fix #2905 fixed one problem--the `bower-lite` hook script wasn't copied
to the Docker image, and so the hook couldn't run, but the other issue
is that the client dependencies are never explicitly built. They must be
built prior to the wheel build, and the hook script must have run so
they are copied to the ./static folder, which is included in the wheel
build thanks to [MANIFEST.in][1]

.. note::

   This removes the verbose flag from the wheel build command. The
   reason is that it generates a lot of writes to stdout. It seems that
   wheel can (or always) is switching to non-blocking mode, which can cause
   EAGAIN to be raised, which leads to fun errors like:

     BlockingIOError(.., 'write could not complete without blocking', ..)

   The wheels fail to build if this error is raised. Removing the verbosity
   flag is a quick solution (it drastically reduces writes to STDOUT), but
   comes at the cost of more trouble debugging a failed wheel build. Adding
   the "-v" back in the Dockerfile when debugging a build failure is still
   possible. [Credit: @vbraun][2]

.. note::

   This commit also removes some extraneous COPY operations during the
   Docker build, in particular the /src/jupyterhub/share directory is
   not used unless users have explicitly override their
   jupyterhub_config.py to include it somehow. If the default
   data_files_path behavior is used, JupyterHub should find the proper
   static directory when the application loads.

Fixes: #2852

[1]: https://packaging.python.org/guides/using-manifest-in/
[2]:
https://github.com/travis-ci/travis-ci/issues/4704#issuecomment-348435959
2020-06-11 15:15:56 -05:00
Yuvi Panda
8db8df6d7a Merge pull request #3081 from minrk/env-config-priority 2020-06-11 18:23:37 +05:30
Min RK
744430ba76 Merge pull request #3059 from GeorgianaElena/jh-demo-img 2020-06-11 10:45:01 +02:00
Min RK
45b858c5af Merge pull request #3055 from minrk/document-admin-service 2020-06-11 10:43:23 +02:00
Min RK
d4b5373c05 synchronize implementation of expiring values
- base Expiring class
- ensures expiring values (OAuthCode, OAuthAccessToken, APIToken) are not returned from `find`
- all expire appropriately via purge_expired
2020-06-11 10:40:06 +02:00
Min RK
aba55cc093 implement UserDict.get
behaves more like one would expect (same as try get-key, except: return default)
without relying on cache presence or underlying key type (integer only)
2020-06-11 10:32:55 +02:00
Min RK
5957a37933 Merge pull request #3079 from manics/allow_fail-masterdeps 2020-06-11 10:31:19 +02:00
Min RK
d20a33a0e4 Merge pull request #3078 from gatoniel/patch-1 2020-06-11 10:30:21 +02:00
Min RK
df35268bfe make Spawner.environment config highest priority
so that it can override 'default' env variables like JUPYTERHUB_API_URL

use with caution!
2020-06-11 09:45:18 +02:00
Simon Li
c357d02b56 Allow python:3.8 + master dependencies to fail
Follow-up from https://github.com/jupyterhub/jupyterhub/pull/3076
2020-06-10 14:53:58 +01:00
niklas netter
4eb22821f2 no_proxy does work 2020-06-10 14:51:37 +02:00
niklas netter
b92ea54eda proxy settings might cause authentication errors 2020-06-10 14:16:36 +02:00
Bruno P. Kinoshita
522ef3daea Add Configuration Reference 2020-06-08 23:49:31 +12:00
Tim Head
77edffd695 Merge pull request #3076 from Carreau/traitlets-master
Test with some master dependencies.
2020-06-07 09:33:23 +02:00
Matthias Bussonnier
a8bc4f8a4a Test with some master dependencies.
This does some of the test with the latest traitlets.
We are looking into making a 5.0 release and would like to have some
confidence that it does not break too many things.
2020-06-05 15:05:10 -07:00
Georgiana Elena
66c3760b02 Update jupyterhub/app.py
Co-authored-by: Erik Sundell <erik.i.sundell@gmail.com>
2020-06-03 14:30:51 +03:00
Erik Sundell
fd28e224f2 Merge pull request #3067 from Zsailer/telemetry-dependency
pin jupyter_telemetry dependency
2020-06-01 23:03:30 +02:00
Zsailer
da3fedb5aa pin jupyter_telemetry dependency 2020-06-01 12:41:22 -07:00
GeorgianaElena
e4e4d472b8 Add JupyterHub Demo docker image 2020-05-28 17:35:42 +03:00
GeorgianaElena
bcbc68dd82 Warn if both bind_url and ip/port/base_url are set 2020-05-27 21:05:01 +03:00
Simon Li
c7df0587d2 Merge pull request #3056 from GeorgianaElena/remove_issue_templates
Use the issue templates from the central repo
2020-05-26 15:56:11 +01:00
GeorgianaElena
cd36733858 Remove the issue templates so that the ones from the central repo jupyterhub/.github take effect 2020-05-26 14:49:36 +03:00
Min RK
6bf4f3b2aa document upgrading from api_tokens to services config 2020-05-26 13:40:21 +02:00
Simon Li
12d81ac07a Merge pull request #3054 from jtpio/black-github-link
Update links to the black GitHub repository
2020-05-25 18:49:27 +01:00
Jeremy Tuloup
d60fa9a400 Update links to the black GitHub repository 2020-05-25 16:10:08 +02:00
Min RK
81d423d6c6 Merge pull request #3040 from romainx/#1018 2020-05-19 15:06:37 +02:00
Min RK
069b477ff3 Merge pull request #3013 from twalcari/feature/spawn_query_arguments 2020-05-19 15:06:17 +02:00
Min RK
cf9046ea47 Merge pull request #3046 from ceocoder/patch-1 2020-05-19 14:58:14 +02:00
Min RK
71a25d4514 Merge pull request #3042 from rabsr/2925_start_my_server_fails 2020-05-19 14:57:44 +02:00
Tim Head
2ff7d05b15 Merge pull request #3047 from consideRatio/health-endpoint-debug-log 2020-05-15 14:39:08 +02:00
Tim Head
bdb29df82a Merge pull request #3048 from mhwasil/disable-proxy-buffering 2020-05-15 14:38:28 +02:00
Mohammad Wasil
0dbad9bd99 Disable proxy_buffering to make the progress bar working when using nginx reverse proxy 2020-05-15 13:51:50 +02:00
Erik Sundell
2991d2d1f1 Log successful /health requests on the debug level
They are less relevant than other request and could very well end up
cluttering the logs. It is not uncomming for these requests to be made
every second or every other second.
2020-05-15 11:48:48 +02:00
dp
a36a56b4ff docs: add proxy_http_version 1.1
add proxy_http_version 1.1 as it is required for KeepAlive connections
2020-05-14 16:16:07 -07:00
romainx
0e59ab003a Readme updated according to review 2020-05-11 14:54:32 +02:00
ragar64
d67b71b7ae " #2925 : Changing start my server button link to spawn url once server is stopped" 2020-05-08 21:47:41 +05:30
romainx
8859bf8842 #1018 PAM added in prerequisites 2020-05-08 06:06:42 +02:00
Thijs Walcarius
4e29342711 Test error path when parsing query arguments 2020-05-06 11:27:04 +02:00
Min RK
8a3790b01f Adding pagination in the admin panel (#2929)
Adding pagination in the admin panel
2020-05-06 11:09:54 +02:00
Min RK
0d245fe4e4 move pagination info next to pagination links
at the bottom
2020-05-06 10:47:08 +02:00
Min RK
da34c6cb34 remove hardcoded path from pagination links
allows pagination of other pages
2020-05-06 10:44:53 +02:00
Min RK
9c0e5ba9c2 Merge pull request #2971 from mriedem/issues/2970-singleuser-version-logging
Only log hub / singleuser version mismatch once
2020-05-06 09:23:04 +02:00
Tim Head
289c3bc3c1 Merge pull request #3035 from vilhelmen/server_ver 2020-05-05 10:00:27 +02:00
Yuvi Panda
3adfec0693 Merge pull request #3020 from stv0g/ipv6-spawner-ip
Support kubespawner running on a IPv6 only cluster
2020-05-04 08:14:01 +05:30
Will Starms
137591f458 remove fixed position, causes Z ordering issues with the bottom of the users list 2020-05-01 19:09:19 -05:00
Michael Blackmon
debd297494 restrict submit handler to only operate on targeted form 2020-04-20 11:33:38 -04:00
Michael Blackmon
10bb5ef3c0 wrap button & widget in feedback-container; add js block with onsubmit handler 2020-04-20 11:00:40 -04:00
Michael Blackmon
42e7d1a3fb put submit button & widget in feedback-container; extend template to include script block with form onsubmit handler 2020-04-20 10:59:34 -04:00
Michael Blackmon
5fbd2838c9 add style class for feedback, widget and container 2020-04-20 10:39:57 -04:00
Michael Blackmon
17dde3a2a9 remove margin styling from submit button 2020-04-20 10:38:19 -04:00
Tim Head
8d50554849 Merge pull request #3022 from joshmeek/docs/index_verbage 2020-04-18 21:17:21 +02:00
Tim Head
493eb03345 Merge branch 'master' into docs/index_verbage 2020-04-18 21:04:11 +02:00
Tim Head
1beac49f4a Merge pull request #3015 from jtpio/admin-template 2020-04-18 19:01:39 +02:00
Tim Head
f230be5ede Merge branch 'master' into admin-template 2020-04-18 15:44:05 +02:00
Steffen Vogel
6283e7ec83 support kubespawner running on a IPv6 only cluster 2020-04-17 19:36:56 +02:00
Thijs Walcarius
2438766418 Show error message when spawning via query-arguments failed. Add options_from_query function 2020-04-17 16:47:55 +02:00
Thijs Walcarius
6f2e409fb9 Allow bypassing of spawn form by calling options in query arguments of /spawn 2020-04-17 16:47:55 +02:00
Carol Willing
aa459aeb39 Merge pull request #3021 from rkdarst/fix-docs
Fix docs CI test failure: duplicate object description
2020-04-17 07:35:24 -07:00
Richard Darst
9d6e8e6b6f Temporary patch autodoc-traits to fix build error [temporary]
- This commit should be removed later after autodoc-traits is fixed upstream
2020-04-17 11:43:49 +03:00
Richard Darst
e882e7954c docs: use recommonmark as an extension
- source_parsers deprecated in sphinx 3.0
- Since sphinx 1.4, it can (should) be used as a direct extension:
  https://github.com/readthedocs/recommonmark/pull/43
2020-04-17 11:11:24 +03:00
Richard Darst
c234463a67 sphinx conf.py: update add_stylesheet -> add_css_file
- Seems to be added in 1.0:
  https://www.sphinx-doc.org/en/latest/changes.html#release-1-0-jul-23-2010
2020-04-17 11:11:24 +03:00
Georgiana Elena
391320a590 Merge pull request #3025 from twalcari/patch-1
Fix broken test due to BeautifulSoup 4.9.0 behavior change
2020-04-17 11:10:25 +03:00
Thijs Walcarius
8648285375 Fix broken test due to BeautifulSoup 4.9.0 behavior change
cfr. https://bugs.launchpad.net/beautifulsoup/+bug/1871335
2020-04-17 10:00:25 +02:00
Josh Meek
485c7b72c2 Fix use of auxiliary verb on index.rst 2020-04-16 09:36:52 -04:00
Steffen Vogel
e93cc83d58 remove unused imports 2020-04-16 12:12:22 +02:00
Jeremy Tuloup
39b9f592b6 Fix user_row endblock in admin template 2020-04-08 17:22:25 +02:00
Tim Head
1f515464fe Merge pull request #3010 from GeorgianaElena/pip_for_docs
Use pip instead of conda for building the docs on RTD
2020-04-02 13:50:27 +02:00
GeorgianaElena
854d0cbb86 Add package requirements to docs build 2020-04-02 10:32:11 +03:00
GeorgianaElena
87212a7414 Remove comment referencing conda environment 2020-04-02 08:55:04 +03:00
GeorgianaElena
2338035df2 Use latest rtd docker image 2020-04-01 14:25:08 +03:00
GeorgianaElena
ea132ff88d Downgrade bootprint 2020-04-01 14:23:35 +03:00
GeorgianaElena
78c14c05f3 Switch to pip on rtd 2020-04-01 14:23:35 +03:00
Erik Sundell
1d2b36e9b0 Merge pull request #3001 from GeorgianaElena/update_issue_templates
Update issue templates
2020-03-26 19:16:40 +01:00
Georgiana Elena
a929ff84c7 Update .github/ISSUE_TEMPLATE/config.yml
Co-Authored-By: Simon Li <orpheus+devel@gmail.com>
2020-03-26 20:03:02 +02:00
GeorgianaElena
0d5bbc16cf Hide comments 2020-03-26 18:30:50 +02:00
GeorgianaElena
ee1fd5a469 Have less issue templates 2020-03-26 18:14:26 +02:00
GeorgianaElena
a702f36524 Update issue templates 2020-03-26 18:14:26 +02:00
GeorgianaElena
59edc6d369 Redirect support questions to Discourse 2020-03-26 18:14:26 +02:00
Georgiana Elena
907b77788d Merge pull request #2978 from danlester/master
SpawnHandler POST with user form options displays the spawn-pending page
2020-03-26 17:13:08 +02:00
Georgiana Elena
914a3eaba5 Merge pull request #2997 from thuvh/fix_typo_installation_guide_hard
fix docs firewall instructions
2020-03-26 16:32:09 +02:00
Hoai-Thu Vuong
b1f048f2ef fix wrong name on firewall 2020-03-24 00:03:26 +07:00
Carol Willing
53d76ad3a2 Merge pull request #2995 from jupyterhub/choldgraf-patch-1
updating docs theme
2020-03-23 08:49:26 -07:00
Chris Holdgraf
7af70b92e9 Update conf.py 2020-03-23 08:29:52 -07:00
Chris Holdgraf
3425eca4ff updating docs theme 2020-03-23 08:10:49 -07:00
Carol Willing
9e0bf9cd9f Merge pull request #2944 from minrk/one-to-one
make spawner:server relationship explicitly one to one
2020-03-22 09:22:46 -07:00
Carol Willing
3118918098 Update jupyterhub/app.py
Minor comment edit
2020-03-22 09:09:49 -07:00
Carol Willing
6a995c822c Merge pull request #2972 from mriedem/contributor-docs
Update contributor docs
2020-03-22 09:04:29 -07:00
Matt Riedemann
a09f535e8f Log hub/singleuser version mismatch once per combo
In case there are multiple singleuser notebooks at different
versions we want to log each of those mismatches as a warning
so this changes the global _version_mismatch_warning_logged flag
from a bool to a dict keyed by the hub/singleuser version mismatch
combination. A test wrinkle is added for that scenario.

Part of #2970
2020-03-16 11:10:13 -04:00
Dan Lester
a60ac53c87 black formatting 2020-03-12 12:44:34 +00:00
Min RK
d2c81bc1d0 Merge pull request #2966 from mriedem/issues/2965-doc-user-options
api-ref: document user_options for server resource
2020-03-12 13:04:25 +01:00
Dan Lester
3908c6d041 SpawnHandler POST with user form options displays the spawn-pending page just like the GET handler always did 2020-03-10 16:17:01 +00:00
Matt Riedemann
c50e1f9852 Update contributor doc wording around sqlite
sqlite3 should be available from the python standard library
so there shouldn't be a need to install native packages.
2020-03-09 15:11:45 -04:00
Matt Riedemann
6954e03bb4 Update contributor docs
As a new contributor to jupyterhub it took awhile to get
up and running locally mainly because I didn't have sqlite
installed but also because I was flipping between README,
CONTRIBUTING and the actual contributing docs which are all
a little bit different.

This does a few things:

- Updates the contributor sphinx docs to mention that how
  one chooses to isolate their development environment is
  up to them with a link to the detailed forum thread on
  that topic.
- Updates the contributor sphinx docs to mention sqlite and
  database setup in general. While in here some trailing
  whitespaces are cleaned up.
- Leave a comment in CONTRIBUTING.md about the redundant
  information in the docs on getting a development environment
  setup. Long-term we should really get those merged so there
  is a single authoritative document on how to get a dev env
  setup for contributing to jupyterhub.
- Link to the jupyterhub gitter channel for asking questions.
2020-03-04 13:09:48 -05:00
Matt Riedemann
08eee9309e Only log hub / singleuser version mismatch once
If your jupyterhub and jupyterhub-singleuser instances
are running at different minor or greater versions a
warning gets logged per active server which can be a lot
when you have hundreds of active servers.

This adds a flag to that version mismatch logging logic
such that the warning is only logged once per restart
of the hub server.

Closes issue #2970
2020-03-04 11:40:23 -05:00
Juan Cruz-Benito
6ed41b38ed Improving pagination for last pages, show always the last page 2020-03-03 14:50:06 +01:00
Matt Riedemann
6b521e0b86 api-ref: document user_options for server resource
APIHandler.server_model unconditionally returns the Spawner's
user_options dict but it wasn't mentioned in the API reference
so it's added here. The description is taken from the docstring
on Spawner.user_options.

Closes issue #2965
2020-03-02 12:12:29 -05:00
Tim Head
1bdc66c75b Merge pull request #2960 from jtpio/named-servers-enter
Start named servers by pressing the Enter key
2020-03-02 10:11:32 +01:00
Jeremy Tuloup
e30b2ca875 Remove unused variables in home.js 2020-03-02 10:02:38 +01:00
Juan Cruz-Benito
1f3ed58570 Fixing pagination numbers. We begin at page 1 not 0 2020-02-28 18:03:29 +01:00
Juan Cruz-Benito
6a31b640c1 Removing more f-strings 2020-02-28 17:56:13 +01:00
Juan Cruz-Benito
ed97150311 Fixing check 2020-02-28 17:53:54 +01:00
Juan Cruz-Benito
78eb77f157 Enforcing checks of page number 2020-02-28 17:47:12 +01:00
Juan Cruz-Benito
f152288d76 Replacing f strings 2020-02-28 17:46:50 +01:00
Juan Cruz-Benito
492c5072b7 Removing print statements 🤦‍♂️ 2020-02-28 17:31:19 +01:00
Juan Cruz-Benito
534e251f97 Adding links generation inside the Pagination class 2020-02-28 17:15:19 +01:00
Jeremy Tuloup
cfcd85a188 Start named servers by pressing the Enter key 2020-02-28 15:24:37 +01:00
Erik Sundell
fd3b5ebbad Merge pull request #2959 from jtpio/patch-1
Add .vscode to gitignore
2020-02-28 15:05:34 +01:00
Jeremy Tuloup
1a2d5913eb Add .vscode to gitignore 2020-02-28 14:55:41 +01:00
Juan Cruz-Benito
8f46d89ac0 Adding info method to pagination and related items in admin template 2020-02-28 13:19:53 +01:00
Juan Cruz-Benito
e82c06cf93 Removing display_msg and record name since it can be coded directly as they're needed in the templates 2020-02-28 12:31:53 +01:00
Juan Cruz-Benito
392525571f Documenting get_page_args method 2020-02-28 12:14:59 +01:00
Juan Cruz-Benito
53927f0490 Pre-commit fixes 2020-02-28 12:05:50 +01:00
Juan Cruz-Benito
ede71db11a Moving Pagination class to its own file 2020-02-28 12:04:53 +01:00
Juan Cruz-Benito
a2e2b1d512 As pointed out in the PR, Pagination isn't a Handler 2020-02-28 12:01:56 +01:00
Tim Head
cff18992ad Merge pull request #2953 from minrk/auth-bearer
preserve auth type when logging obfuscated auth header
2020-02-28 11:48:10 +01:00
Tim Head
b2c0b5024c Merge pull request #2956 from manics/pin-sphinx-theme
[MRG] Pin sphinx theme
2020-02-28 11:28:21 +01:00
Simon Li
996483de94 Pin sphinx theme (https://github.com/jupyterhub/binderhub/pull/1070)
Closes https://github.com/jupyterhub/jupyterhub/issues/2955
2020-02-27 17:35:52 +00:00
Min RK
f4b7b85b02 preserve auth type when logging obfuscated auth header
Authorization header has the form "<type> <credentials>"

rather than checking for "token" only, preserve type value, which could be Bearer, Basic, etc.
2020-02-27 13:49:47 +01:00
Min RK
b4391d0f79 Merge pull request #2952 from kinow/fix-spawn-url
Keep the URL fragments after spawning an application
2020-02-26 14:05:45 +01:00
Juan Cruz-Benito
f49cc1fcf0 Improving description of potential parameters 2020-02-26 10:40:44 +01:00
Juan Cruz-Benito
18205fbf4a Fixing black formatting issues 2020-02-26 10:36:36 +01:00
Bruno P. Kinoshita
2f6ea71106 Add not_running.js to modify button spawn_url 2020-02-26 09:28:12 +13:00
Juan Cruz-Benito
7b6ac158cc Removing python-paginate package and adding minimal Pagination class to enable a pagination API for AdminHandler 2020-02-25 19:11:09 +01:00
Juan Cruz-Benito
facf52f117 Removing unneeded pass of request to the template 2020-02-25 17:03:01 +01:00
Juan Cruz-Benito
f36796dd85 Merge branch 'master' into add_pagination_admin 2020-02-25 17:01:01 +01:00
Min RK
0427f8090f Merge pull request #2773 from kinow/fix-ssl-url-message
Handle the protocol when ssl is enabled and log the right URL
2020-02-25 13:35:32 +01:00
Tim Head
da86eaad97 Merge pull request #2951 from kinow/typos-2
[doc] Fix couple typos in the documentation
2020-02-24 09:21:31 +01:00
Bruno P. Kinoshita
3b05135f11 Fix couple typos 2020-02-24 20:48:42 +13:00
Bruno P. Kinoshita
76afec8adb Update app.bind_url and proxy.public_url when (external) SSL is enabled 2020-02-24 15:51:09 +13:00
Tim Head
06da90ac76 Merge pull request #2950 from alexdriedger/patch-2
Docs: Fixed grammar on landing page
2020-02-23 09:11:43 +01:00
Alex Driedger
7e3caf7f48 Fixed grammar on landing page 2020-02-22 16:37:34 -08:00
Tim Head
e08552eb99 Merge pull request #2941 from minrk/allow-implicit-spawn
Allow implicit spawn via javascript redirect
2020-02-22 07:27:17 +01:00
Tim Head
5fb403af4b Merge pull request #2946 from minrk/user-redirect-faq
add general faq
2020-02-22 07:24:24 +01:00
Min RK
84acdd5a7f handle uselist=False in our relationship expiry 2020-02-21 14:10:36 +01:00
Min RK
3e6abb7a5e add general faq
and put a first q about user-redirect
2020-02-21 13:52:03 +01:00
Min RK
0315f986db Merge pull request #2940 from kinow/add-more-docs-for-cookies
[doc] Add more docs about Cookies used for authentication in JupyterHub
2020-02-21 10:18:29 +01:00
Min RK
7735c7ddd4 make spawner:server backref explicitly one-to-one
using backref(uselist=False), single_parent=True
2020-02-21 10:09:08 +01:00
Bruno P. Kinoshita
239a4c63a2 Add note that not all proxy implementations use an auth token 2020-02-21 10:35:30 +13:00
Bruno P. Kinoshita
f5bd5b7751 Incorporate review feedback 2020-02-21 10:32:11 +13:00
Bruno P. Kinoshita
287b0302d9 Add more docs about authentication and cookies, using text posted by MinRK on Discourse 2020-02-21 10:22:10 +13:00
Tim Head
44e23aad78 Merge pull request #2936 from minrk/make-it-fast-break-everything-maybe
make init_spawners check O(running servers) not O(total users)
2020-02-20 17:06:24 +01:00
Tim Head
606775f72d Remove unused variable 2020-02-20 16:56:03 +01:00
Min RK
9a6308f8d9 docs: use metachannel for faster environment solve (#2943)
docs: use metachannel for faster environment solve
2020-02-20 15:55:36 +01:00
Min RK
0c4db2d99f docs: use metachannel for faster environment solve
rtd is having memory issues with conda-forge, which should hopefully be fixed by metachannel

this should also make things quicker for anyone
2020-02-20 15:54:43 +01:00
Min RK
938970817c update docs environments (#2942)
update docs environments
2020-02-20 15:36:10 +01:00
Min RK
d2a1b8e349 update docs environments
- python 3.7
- node 12
- sync recommonmark 0.6
2020-02-20 15:32:55 +01:00
Min RK
4477506345 Merge pull request #2930 from JohnPaton/patch-1
Add favicon to the base page template
2020-02-20 14:23:06 +01:00
Min RK
0787489e1b maybe_future needs a future! 2020-02-20 12:53:15 +01:00
Min RK
436757dd55 handle implicit spawn with a javascript redirect
less dangerous than using a Location redirect, so remove conflicts

delay is a user-configurable timer (0 = no implicit spawn, default)
2020-02-20 12:43:39 +01:00
Min RK
a0b6d8ec6f add allow_implicit_spawn setting
- warn that there are known issues associated with enabling it
- it is inherently incompatible with named servers
2020-02-20 12:12:55 +01:00
Min RK
b92efcd7b0 spawner test assumed app.users is populated 2020-02-20 09:37:08 +01:00
Erik Sundell
3e17b47ec3 Merge pull request #2939 from kinow/fix-services-link
[doc] Use fixed commit plus line number in github link
2020-02-19 01:09:51 +01:00
Bruno P. Kinoshita
31c0788bd9 Move cookies to the end of the list (ssl, proxy, and then cookies) 2020-02-19 12:56:02 +13:00
Bruno P. Kinoshita
dec3244758 Use fixed commit plus line number in github link 2020-02-19 12:39:23 +13:00
Erik Sundell
91e385efa7 Merge pull request #2938 from kinow/fix-link-to-ssl-doc
[doc] Fix link to SSL encryption from troubleshooting page
2020-02-18 22:55:07 +01:00
Bruno P. Kinoshita
13313abb37 Fix link to SSL encryption from troubleshooting page 2020-02-19 10:46:49 +13:00
Min RK
79a51dfdce make init_spawners check O(running servers) not O(total users)
query on Server objects instead of User objects

avoids lots of ORM work on startup since there are typically a small number of running servers
relative to the total number of users

this also means that the users dict is not fully populated. Is that okay? I hope so.
2020-02-18 17:10:19 +01:00
JohnPaton
a999ac8f07 Use only rel="icon"
This is the officially recommended method from MDN
https://developer.mozilla.org/en-US/docs/Learn/HTML/Introduction_to_HTML/The_head_metadata_in_HTML
2020-02-14 16:51:27 +01:00
John Paton
a3e3f24d2d Add favicon to the base page template
This was missing before. Giving it its own named block will let users customize it if they wish.
2020-02-14 16:35:48 +01:00
Juan Cruz-Benito
b2b85eb548 Improving comments 2020-02-14 11:47:43 +01:00
Juan Cruz-Benito
95c5ebb090 Fixing pre-commit errors 2020-02-14 11:14:07 +01:00
Juan Cruz-Benito
3d0da4f25a Adding python-paginate package and using it to paginate admin panel 2020-02-13 18:35:17 +01:00
Tim Head
bc7bb5076f Merge pull request #2914 from jgwerner/trouble-shooting
[MRG] Add troubleshooting topics
2020-02-13 08:06:20 +01:00
Greg
a80561bfc8 updates based on pr comments
Signed-off-by: Greg <werner.greg@gmail.com>
2020-02-05 16:13:15 -05:00
Erik Sundell
22f86ad76c Merge pull request #2917 from minrk/doc-remove
rest api: fix schema for remove parameter in rest api
2020-01-31 17:31:31 +01:00
Min RK
0ae9cfa42f fix schema for remove parameter in rest api
it wasn't showing up properly since it's a *property* of the body, not the body itself
2020-01-31 17:18:30 +01:00
Min RK
ff8c4ca8a3 update bootprint to v4 2020-01-31 17:16:57 +01:00
Greg
ed4ed4de9d simplify text
Signed-off-by: Greg <werner.greg@gmail.com>
2020-01-29 12:49:52 -05:00
Greg
d177b99f3a add trouble shooting topics
Signed-off-by: Greg <werner.greg@gmail.com>
2020-01-29 12:42:42 -05:00
Erik Sundell
65de8c4916 Merge pull request #2904 from reneluria/patch-doc
Several fixes to the doc
2020-01-24 17:25:36 +01:00
Min RK
178f9d4c51 Merge pull request #2905 from consideRatio/solve-docker-template-issue
Add what we need with some margin to Dockerfile's build stage
2020-01-23 09:57:12 +01:00
Min RK
9433564c5b bump reorder-imports hook (#2899)
bump reorder-imports hook
2020-01-23 09:54:46 +01:00
Erik Sundell
5deba0c4ba Copy all files to Dockerfile's build stage
Not exactly all though as some will be ignored by the .dockerignore
file. This change ensures we don't get future issues caused by a failure
to update what needs to be copied to the build stage and not like we've
had recently.
2020-01-23 07:20:53 +01:00
Erik Sundell
5234d4c7ae Add bower-lite script to Dockerfile
This fixes #2852 by adding a script part of package.json. But is this
enough? Should we perhaps look in MANIFEST.in and copy some more files
listed there?

This is all thanks to people coming together and helping out figuring
out the issue in https://github.com/jupyterhub/jupyterhub/issues/2852.
Thank you @shingo78 for spotting that we missed bower-lite and its role
and all others who reported and helped debug this!
2020-01-23 07:20:40 +01:00
Erik Sundell
1bea28026e Merge pull request #2907 from consideRatio/fix-generate-config-bug
Fix --generate-config bug when specifying a filename
2020-01-23 07:19:11 +01:00
Erik Sundell
9a5c8ff058 Fix --generate-config bug when specifying a filename
This commit fixes #2906 that was introduced due to #2824. See analysis
of issue in
https://github.com/jupyterhub/jupyterhub/issues/2906#issuecomment-577303510.
2020-01-22 19:30:16 +01:00
Rene Luria
2b183c9773 Several fixes to the doc
* sudo for configurable-http-proxy install
* fix sudo command for apt source
* fix $connection_upgrade variable in nginx configuration
2020-01-21 17:02:23 +01:00
Tim Head
5dee864afd fix: 'Non-ASCII character '\xc3' (#2901)
fix: 'Non-ASCII character '\xc3'
2020-01-20 09:15:56 +01:00
Greg
6fdf931515 update prometheus_log_method comments
Signed-off-by: Greg <werner.greg@gmail.com>
2020-01-17 12:32:50 -05:00
Greg
d126baa443 remove diaeresis
Signed-off-by: Greg <werner.greg@gmail.com>
2020-01-17 09:43:46 -05:00
Min RK
d1e2d593ff back to dev 2020-01-17 12:55:42 +01:00
Min RK
800b6a6bc5 bump reorder-imports
removes (hopefully) unnecessarily specified language version
2020-01-17 12:48:17 +01:00
yuvipanda
e9bc25cce0 Run all tests for jupyter_server regardless of failure 2019-06-04 14:42:49 +02:00
yuvipanda
8f7e25f9a1 Don't make pip uninstall wait for human input 2019-06-04 14:24:30 +02:00
yuvipanda
399def182b Actually run jupyter_server test on Travis 2019-06-04 13:57:26 +02:00
yuvipanda
f830b2a417 Try to test notebook package is still uninstalled 2019-06-04 13:45:57 +02:00
yuvipanda
cab1bca6fb Use jupyter_server if notebook package isn't available 2019-06-04 13:42:52 +02:00
yuvipanda
5eb7a14a33 [WIP] Add support for Jupyter Server 2019-06-04 13:30:28 +02:00
125 changed files with 3277 additions and 1586 deletions

View File

@@ -19,52 +19,11 @@ jobs:
name: smoke test jupyterhub
command: |
docker run --rm -it jupyterhub/jupyterhub jupyterhub --help
docs:
# This is the base environment that Circle will use
docker:
- image: circleci/python:3.6-stretch
steps:
# Get our data and merge with upstream
- run: sudo apt-get update
- checkout
# Update our path
- run: echo "export PATH=~/.local/bin:$PATH" >> $BASH_ENV
# Restore cached files to speed things up
- restore_cache:
keys:
- cache-pip
# Install the packages needed to build our documentation
- run:
name: Install NodeJS
name: verify static files
command: |
# From https://github.com/nodesource/distributions/blob/master/README.md#debinstall
curl -sL https://deb.nodesource.com/setup_13.x | sudo -E bash -
sudo apt-get install -y nodejs
docker run --rm -it -v $PWD/dockerfiles:/io jupyterhub/jupyterhub python3 /io/test.py
- run:
name: Install dependencies
command: |
python3 -m pip install --user -r dev-requirements.txt
python3 -m pip install --user -r docs/requirements.txt
sudo npm install -g configurable-http-proxy
sudo python3 -m pip install --editable .
# Cache some files for a speedup in subsequent builds
- save_cache:
key: cache-pip
paths:
- ~/.cache/pip
# Build the docs
- run:
name: Build docs to store
command: |
cd docs
make html
# Tell Circle to store the documentation output in a folder that we can access later
- store_artifacts:
path: docs/build/html/
destination: html
# Tell CircleCI to use this workflow when it builds the site
workflows:
@@ -72,4 +31,3 @@ workflows:
default:
jobs:
- build
- docs

View File

@@ -1,39 +0,0 @@
---
name: Issue report
about: Create a report to help us improve
---
<!---
Hi! Thanks for using JupyterHub.
If you are reporting an issue with JupyterHub, please use the GitHub search feature to check if your issue has been asked already. If it has, please add your comments to the existing issue.
Some tips:
- Running `jupyter troubleshoot` from the command line, if possible, and posting
its output would also be helpful.
- Running JupyterHub in `--debug` mode (`jupyterhub --debug`) can also be helpful for troubleshooting.
--->
**Describe the bug**
A clear and concise description of what the bug is.
<!---Add description here--->
**To Reproduce**
<!---
Please share the steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
--->
**Expected behavior**
<!---
A clear and concise description of what you expected to happen.
--->
**Compute Information**
- Operating System
- JupyterHub Version [e.g. 22]

View File

@@ -1,22 +0,0 @@
---
name: Installation and configuration questions
about: Installation and configuration assistance
---
<!---
If you are reading this message, you have probably already searched the existing
GitHub issues for JupyterHub. If you haven't tried a search, we encourage you to do so.
If you are unsure where to ask your question (Jupyter, JupyterHub, JupyterLab, etc.),
please ask on our [Discourse Q&A channel](https://discourse.jupyter.org/c/questions).
If you have a quick question about JupyterHub installation or configuratation, you
may ask on the [JupyterHub gitter channel](https://gitter.im/jupyterhub/jupyterhub).
:sunny: Please be patient. We are volunteers and will address your question when we are able. :sunny:
If after trying the above steps, you still have an in-depth installation or
configuration question, such as a possible bug, please file an issue below and include
any relevant details.
--->

206
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,206 @@
# This is a GitHub workflow defining a set of jobs with a set of steps.
# ref: https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions
#
name: Run tests
# Trigger the workflow's on all PRs but only on pushed tags or commits to
# main/master branch to avoid PRs developed in a GitHub fork's dedicated branch
# to trigger.
on:
pull_request:
push:
defaults:
run:
# Declare bash be used by default in this workflow's "run" steps.
#
# NOTE: bash will by default run with:
# --noprofile: Ignore ~/.profile etc.
# --norc: Ignore ~/.bashrc etc.
# -e: Exit directly on errors
# -o pipefail: Don't mask errors from a command piped into another command
shell: bash
env:
# UTF-8 content may be interpreted as ascii and causes errors without this.
LANG: C.UTF-8
jobs:
# Run "pre-commit run --all-files"
pre-commit:
runs-on: ubuntu-20.04
timeout-minutes: 2
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: 3.8
# ref: https://github.com/pre-commit/action
- uses: pre-commit/action@v2.0.0
- name: Help message if pre-commit fail
if: ${{ failure() }}
run: |
echo "You can install pre-commit hooks to automatically run formatting"
echo "on each commit with:"
echo " pre-commit install"
echo "or you can run by hand on staged files with"
echo " pre-commit run"
echo "or after-the-fact on already committed files with"
echo " pre-commit run --all-files"
# Run "pytest jupyterhub/tests" in various configurations
pytest:
runs-on: ubuntu-20.04
timeout-minutes: 10
strategy:
# Keep running even if one variation of the job fail
fail-fast: false
matrix:
# We run this job multiple times with different parameterization
# specified below, these parameters have no meaning on their own and
# gain meaning on how job steps use them.
#
# subdomain:
# Tests everything when JupyterHub is configured to add routes for
# users with dedicated subdomains like user1.jupyter.example.com
# rather than jupyter.example.com/user/user1.
#
# db: [mysql/postgres]
# Tests everything when JupyterHub works against a dedicated mysql or
# postgresql server.
#
# jupyter_server:
# Tests everything when the user instances are started with
# jupyter_server instead of notebook.
#
# main_dependencies:
# Tests everything when the we use the latest available dependencies
# from: ipytraitlets.
#
# NOTE: Since only the value of these parameters are presented in the
# GitHub UI when the workflow run, we avoid using true/false as
# values by instead duplicating the name to signal true.
include:
- python: "3.6"
subdomain: subdomain
- python: "3.7"
db: mysql
- python: "3.8"
db: postgres
- python: "3.8"
jupyter_server: jupyter_server
- python: "3.9"
main_dependencies: main_dependencies
steps:
# NOTE: In GitHub workflows, environment variables are set by writing
# assignment statements to a file. They will be set in the following
# steps as if would used `export MY_ENV=my-value`.
- name: Configure environment variables
run: |
if [ "${{ matrix.subdomain }}" != "" ]; then
echo "JUPYTERHUB_TEST_SUBDOMAIN_HOST=http://localhost.jovyan.org:8000" >> $GITHUB_ENV
fi
if [ "${{ matrix.db }}" == "mysql" ]; then
echo "MYSQL_HOST=127.0.0.1" >> $GITHUB_ENV
echo "JUPYTERHUB_TEST_DB_URL=mysql+mysqlconnector://root@127.0.0.1:3306/jupyterhub" >> $GITHUB_ENV
fi
if [ "${{ matrix.db }}" == "postgres" ]; then
echo "PGHOST=127.0.0.1" >> $GITHUB_ENV
echo "PGUSER=test_user" >> $GITHUB_ENV
echo "PGPASSWORD=hub[test/:?" >> $GITHUB_ENV
echo "JUPYTERHUB_TEST_DB_URL=postgresql://test_user:hub%5Btest%2F%3A%3F@127.0.0.1:5432/jupyterhub" >> $GITHUB_ENV
fi
if [ "${{ matrix.jupyter_server }}" != "" ]; then
echo "JUPYTERHUB_SINGLEUSER_APP=jupyterhub.tests.mockserverapp.MockServerApp" >> $GITHUB_ENV
fi
- uses: actions/checkout@v2
# NOTE: actions/setup-node@v1 make use of a cache within the GitHub base
# environment and setup in a fraction of a second.
- name: Install Node v14
uses: actions/setup-node@v1
with:
node-version: "14"
- name: Install Node dependencies
run: |
npm install
npm install -g configurable-http-proxy
npm list
# NOTE: actions/setup-python@v2 make use of a cache within the GitHub base
# environment and setup in a fraction of a second.
- name: Install Python ${{ matrix.python }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python }}
- name: Install Python dependencies
run: |
pip install --upgrade pip
pip install --upgrade . -r dev-requirements.txt
if [ "${{ matrix.main_dependencies }}" != "" ]; then
pip install git+https://github.com/ipython/traitlets#egg=traitlets --force
fi
if [ "${{ matrix.jupyter_server }}" != "" ]; then
pip uninstall notebook --yes
pip install jupyter_server
fi
if [ "${{ matrix.db }}" == "mysql" ]; then
pip install mysql-connector-python
fi
if [ "${{ matrix.db }}" == "postgres" ]; then
pip install psycopg2-binary
fi
pip freeze
# NOTE: If you need to debug this DB setup step, consider the following.
#
# 1. mysql/postgressql are database servers we start as docker containers,
# and we use clients named mysql/psql.
#
# 2. When we start a database server we need to pass environment variables
# explicitly as part of the `docker run` command. These environment
# variables are named differently from the similarly named environment
# variables used by the clients.
#
# - mysql server ref: https://hub.docker.com/_/mysql/
# - mysql client ref: https://dev.mysql.com/doc/refman/5.7/en/environment-variables.html
# - postgres server ref: https://hub.docker.com/_/postgres/
# - psql client ref: https://www.postgresql.org/docs/9.5/libpq-envars.html
#
# 3. When we connect, they should use 127.0.0.1 rather than the
# default way of connecting which leads to errors like below both for
# mysql and postgresql unless we set MYSQL_HOST/PGHOST to 127.0.0.1.
#
# - ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/var/run/mysqld/mysqld.sock' (2)
#
- name: Start a database server (${{ matrix.db }})
if: ${{ matrix.db }}
run: |
if [ "${{ matrix.db }}" == "mysql" ]; then
sudo apt-get update
sudo apt-get install -y mysql-client
DB=mysql bash ci/docker-db.sh
DB=mysql bash ci/init-db.sh
fi
if [ "${{ matrix.db }}" == "postgres" ]; then
sudo apt-get update
sudo apt-get install -y postgresql-client
DB=postgres bash ci/docker-db.sh
DB=postgres bash ci/init-db.sh
fi
- name: Run pytest
# FIXME: --color=yes explicitly set because:
# https://github.com/actions/runner/issues/241
run: |
pytest -v --maxfail=2 --color=yes --cov=jupyterhub jupyterhub/tests
- name: Submit codecov report
run: |
codecov

2
.gitignore vendored
View File

@@ -24,5 +24,7 @@ MANIFEST
.coverage.*
htmlcov
.idea/
.vscode/
.pytest_cache
pip-wheel-metadata
docs/source/reference/metrics.rst

View File

@@ -1,10 +1,9 @@
repos:
- repo: https://github.com/asottile/reorder_python_imports
rev: v1.8.0
rev: v1.9.0
hooks:
- id: reorder-python-imports
language_version: python3.6
- repo: https://github.com/ambv/black
- repo: https://github.com/psf/black
rev: 19.10b0
hooks:
- id: black

View File

@@ -1,94 +0,0 @@
dist: bionic
language: python
cache:
- pip
env:
global:
- MYSQL_HOST=127.0.0.1
- MYSQL_TCP_PORT=13306
# request additional services for the jobs to access
services:
- postgresql
- docker
# install dependencies for running pytest (but not linting)
before_install:
- set -e
- nvm install 6; nvm use 6
- npm install
- npm install -g configurable-http-proxy
- |
# setup database
if [[ $JUPYTERHUB_TEST_DB_URL == mysql* ]]; then
unset MYSQL_UNIX_PORT
DB=mysql bash ci/docker-db.sh
DB=mysql bash ci/init-db.sh
# FIXME: mysql-connector-python 8.0.16 incorrectly decodes bytes to str
# ref: https://bugs.mysql.com/bug.php?id=94944
pip install 'mysql-connector-python==8.0.11'
elif [[ $JUPYTERHUB_TEST_DB_URL == postgresql* ]]; then
psql -c "CREATE USER $PGUSER WITH PASSWORD '$PGPASSWORD';" -U postgres
DB=postgres bash ci/init-db.sh
pip install psycopg2-binary
fi
# install general dependencies
install:
- pip install --upgrade pip
- pip install --upgrade --pre -r dev-requirements.txt .
- pip freeze
# run tests
script:
- pytest -v --maxfail=2 --cov=jupyterhub jupyterhub/tests
# collect test coverage information
after_success:
- codecov
# list the jobs
jobs:
include:
- name: autoformatting check
python: 3.6
# NOTE: It does not suffice to override to: null, [], or [""]. Travis will
# fall back to the default if we do.
before_install: echo "Do nothing before install."
script:
- pre-commit run --all-files
after_success: echo "Do nothing after success."
after_failure:
- |
echo "You can install pre-commit hooks to automatically run formatting"
echo "on each commit with:"
echo " pre-commit install"
echo "or you can run by hand on staged files with"
echo " pre-commit run"
echo "or after-the-fact on already committed files with"
echo " pre-commit run --all-files"
# When we run pytest, we want to run it with python>=3.5 as well as with
# various configurations. We increment the python version at the same time
# as we test new configurations in order to reduce the number of test jobs.
- name: python:3.5 + dist:xenial
python: 3.5
dist: xenial
- name: python:3.6 + subdomain
python: 3.6
env: JUPYTERHUB_TEST_SUBDOMAIN_HOST=http://localhost.jovyan.org:8000
- name: python:3.7 + mysql
python: 3.7
env:
- JUPYTERHUB_TEST_DB_URL=mysql+mysqlconnector://root@127.0.0.1:$MYSQL_TCP_PORT/jupyterhub
- name: python:3.8 + postgresql
python: 3.8
env:
- PGUSER=jupyterhub
- PGPASSWORD=hub[test/:?
# The password in url below is url-encoded with: urllib.parse.quote($PGPASSWORD, safe='')
- JUPYTERHUB_TEST_DB_URL=postgresql://jupyterhub:hub%5Btest%2F%3A%3F@127.0.0.1/jupyterhub
- name: python:nightly
python: nightly
allow_failures:
- name: python:nightly
fast_finish: true

View File

@@ -8,6 +8,12 @@ for a friendly and welcoming collaborative environment.
## Setting up a development environment
<!--
https://jupyterhub.readthedocs.io/en/stable/contributing/setup.html
contains a lot of the same information. Should we merge the docs and
just have this page link to that one?
-->
JupyterHub requires Python >= 3.5 and nodejs.
As a Python project, a development install of JupyterHub follows standard practices for the basics (steps 1-2).
@@ -60,7 +66,7 @@ pre-commit run
which should run any autoformatting on your code
and tell you about any errors it couldn't fix automatically.
You may also install [black integration](https://github.com/ambv/black#editor-integration)
You may also install [black integration](https://github.com/psf/black#editor-integration)
into your text editor to format code automatically.
If you have already committed files before setting up the pre-commit
@@ -128,4 +134,4 @@ To read more about fixtures check out the
[pytest docs](https://docs.pytest.org/en/latest/fixture.html)
for how to use the existing fixtures, and how to create new ones.
When in doubt, feel free to ask.
When in doubt, feel free to [ask](https://gitter.im/jupyterhub/jupyterhub).

View File

@@ -21,8 +21,7 @@
# your jupyterhub_config.py will be added automatically
# from your docker directory.
# https://github.com/tianon/docker-brew-ubuntu-core/commit/d4313e13366d24a97bd178db4450f63e221803f1
ARG BASE_IMAGE=ubuntu:bionic-20191029@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d
ARG BASE_IMAGE=ubuntu:focal-20200729@sha256:6f2fb2f9fb5582f8b587837afd6ea8f37d8d1d9e41168c90f410a6ef15fa8ce5
FROM $BASE_IMAGE AS builder
USER root
@@ -41,19 +40,18 @@ RUN apt-get update \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# copy only what we need to avoid unnecessary rebuilds
COPY package.json \
pyproject.toml \
README.md \
requirements.txt \
setup.py \
/src/jupyterhub/
COPY jupyterhub/ /src/jupyterhub/jupyterhub
COPY share/ /src/jupyterhub/share
WORKDIR /src/jupyterhub
RUN python3 -m pip install --upgrade setuptools pip wheel
RUN python3 -m pip wheel -v --wheel-dir wheelhouse .
# copy everything except whats in .dockerignore, its a
# compromise between needing to rebuild and maintaining
# what needs to be part of the build
COPY . /src/jupyterhub/
WORKDIR /src/jupyterhub
# Build client component packages (they will be copied into ./share and
# packaged with the built wheel.)
RUN python3 setup.py bdist_wheel
RUN python3 -m pip wheel --wheel-dir wheelhouse dist/*.whl
FROM $BASE_IMAGE
@@ -90,7 +88,6 @@ RUN npm install -g configurable-http-proxy@^4.2.0 \
# install the wheels we built in the first stage
COPY --from=builder /src/jupyterhub/wheelhouse /tmp/wheelhouse
COPY --from=builder /src/jupyterhub/share /src/jupyterhub/share
RUN python3 -m pip install --no-cache /tmp/wheelhouse/*
RUN mkdir -p /srv/jupyterhub/

View File

@@ -13,7 +13,7 @@
[![Latest PyPI version](https://img.shields.io/pypi/v/jupyterhub?logo=pypi)](https://pypi.python.org/pypi/jupyterhub)
[![Latest conda-forge version](https://img.shields.io/conda/vn/conda-forge/jupyterhub?logo=conda-forge)](https://www.npmjs.com/package/jupyterhub)
[![Documentation build status](https://img.shields.io/readthedocs/jupyterhub?logo=read-the-docs)](https://jupyterhub.readthedocs.org/en/latest/)
[![TravisCI build status](https://img.shields.io/travis/jupyterhub/jupyterhub/master?logo=travis)](https://travis-ci.org/jupyterhub/jupyterhub)
[![TravisCI build status](https://img.shields.io/travis/com/jupyterhub/jupyterhub?logo=travis)](https://travis-ci.com/jupyterhub/jupyterhub)
[![DockerHub build status](https://img.shields.io/docker/build/jupyterhub/jupyterhub?logo=docker&label=build)](https://hub.docker.com/r/jupyterhub/jupyterhub/tags)
[![CircleCI build status](https://img.shields.io/circleci/build/github/jupyterhub/jupyterhub?logo=circleci)](https://circleci.com/gh/jupyterhub/jupyterhub)<!-- CircleCI Token: b5b65862eb2617b9a8d39e79340b0a6b816da8cc -->
[![Test coverage of code](https://codecov.io/gh/jupyterhub/jupyterhub/branch/master/graph/badge.svg)](https://codecov.io/gh/jupyterhub/jupyterhub)
@@ -74,6 +74,7 @@ for administration of the Hub and its users.
The `nodejs-legacy` package installs the `node` executable and is currently
required for npm to work on Debian/Ubuntu.
- If using the default PAM Authenticator, a [pluggable authentication module (PAM)](https://en.wikipedia.org/wiki/Pluggable_authentication_module).
- TLS certificate and key for HTTPS communication
- Domain name

View File

@@ -1,36 +1,55 @@
#!/usr/bin/env bash
# source this file to setup postgres and mysql
# for local testing (as similar as possible to docker)
# The goal of this script is to start a database server as a docker container.
#
# Required environment variables:
# - DB: The database server to start, either "postgres" or "mysql".
#
# - PGUSER/PGPASSWORD: For the creation of a postgresql user with associated
# password.
set -eu
export MYSQL_HOST=127.0.0.1
export MYSQL_TCP_PORT=${MYSQL_TCP_PORT:-13306}
export PGHOST=127.0.0.1
NAME="hub-test-$DB"
DOCKER_RUN="docker run -d --name $NAME"
# Stop and remove any existing database container
DOCKER_CONTAINER="hub-test-$DB"
docker rm -f "$DOCKER_CONTAINER" 2>/dev/null || true
docker rm -f "$NAME" 2>/dev/null || true
case "$DB" in
"mysql")
RUN_ARGS="-e MYSQL_ALLOW_EMPTY_PASSWORD=1 -p $MYSQL_TCP_PORT:3306 mysql:5.7"
CHECK="mysql --host $MYSQL_HOST --port $MYSQL_TCP_PORT --user root -e \q"
;;
"postgres")
RUN_ARGS="-p 5432:5432 postgres:9.5"
CHECK="psql --user postgres -c \q"
;;
*)
# Prepare environment variables to startup and await readiness of either a mysql
# or postgresql server.
if [[ "$DB" == "mysql" ]]; then
# Environment variables can influence both the mysql server in the docker
# container and the mysql client.
#
# ref server: https://hub.docker.com/_/mysql/
# ref client: https://dev.mysql.com/doc/refman/5.7/en/setting-environment-variables.html
#
DOCKER_RUN_ARGS="-p 3306:3306 --env MYSQL_ALLOW_EMPTY_PASSWORD=1 mysql:5.7"
READINESS_CHECK="mysql --user root --execute \q"
elif [[ "$DB" == "postgres" ]]; then
# Environment variables can influence both the postgresql server in the
# docker container and the postgresql client (psql).
#
# ref server: https://hub.docker.com/_/postgres/
# ref client: https://www.postgresql.org/docs/9.5/libpq-envars.html
#
# POSTGRES_USER / POSTGRES_PASSWORD will create a user on startup of the
# postgres server, but PGUSER and PGPASSWORD are the environment variables
# used by the postgresql client psql, so we configure the user based on how
# we want to connect.
#
DOCKER_RUN_ARGS="-p 5432:5432 --env "POSTGRES_USER=${PGUSER}" --env "POSTGRES_PASSWORD=${PGPASSWORD}" postgres:9.5"
READINESS_CHECK="psql --command \q"
else
echo '$DB must be mysql or postgres'
exit 1
esac
fi
$DOCKER_RUN $RUN_ARGS
# Start the database server
docker run --detach --name "$DOCKER_CONTAINER" $DOCKER_RUN_ARGS
# Wait for the database server to start
echo -n "waiting for $DB "
for i in {1..60}; do
if $CHECK; then
if $READINESS_CHECK; then
echo 'done'
break
else
@@ -38,22 +57,4 @@ for i in {1..60}; do
sleep 1
fi
done
$CHECK
case "$DB" in
"mysql")
;;
"postgres")
# create the user
psql --user postgres -c "CREATE USER $PGUSER WITH PASSWORD '$PGPASSWORD';"
;;
*)
esac
echo -e "
Set these environment variables:
export MYSQL_HOST=127.0.0.1
export MYSQL_TCP_PORT=$MYSQL_TCP_PORT
export PGHOST=127.0.0.1
"
$READINESS_CHECK

View File

@@ -1,27 +1,26 @@
#!/usr/bin/env bash
# initialize jupyterhub databases for testing
# The goal of this script is to initialize a running database server with clean
# databases for use during tests.
#
# Required environment variables:
# - DB: The database server to start, either "postgres" or "mysql".
set -eu
MYSQL="mysql --user root --host $MYSQL_HOST --port $MYSQL_TCP_PORT -e "
PSQL="psql --user postgres -c "
case "$DB" in
"mysql")
EXTRA_CREATE='CHARACTER SET utf8 COLLATE utf8_general_ci'
SQL="$MYSQL"
;;
"postgres")
SQL="$PSQL"
;;
*)
# Prepare env vars SQL_CLIENT and EXTRA_CREATE_DATABASE_ARGS
if [[ "$DB" == "mysql" ]]; then
SQL_CLIENT="mysql --user root --execute "
EXTRA_CREATE_DATABASE_ARGS='CHARACTER SET utf8 COLLATE utf8_general_ci'
elif [[ "$DB" == "postgres" ]]; then
SQL_CLIENT="psql --command "
else
echo '$DB must be mysql or postgres'
exit 1
esac
fi
# Configure a set of databases in the database server for upgrade tests
set -x
for SUFFIX in '' _upgrade_072 _upgrade_081 _upgrade_094; do
$SQL "DROP DATABASE jupyterhub${SUFFIX};" 2>/dev/null || true
$SQL "CREATE DATABASE jupyterhub${SUFFIX} ${EXTRA_CREATE:-};"
$SQL_CLIENT "DROP DATABASE jupyterhub${SUFFIX};" 2>/dev/null || true
$SQL_CLIENT "CREATE DATABASE jupyterhub${SUFFIX} ${EXTRA_CREATE_DATABASE_ARGS:-};"
done

16
demo-image/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
# Demo JupyterHub Docker image
#
# This should only be used for demo or testing and not as a base image to build on.
#
# It includes the notebook package and it uses the DummyAuthenticator and the SimpleLocalProcessSpawner.
ARG BASE_IMAGE=jupyterhub/jupyterhub-onbuild
FROM ${BASE_IMAGE}
# Install the notebook package
RUN python3 -m pip install notebook
# Create a demo user
RUN useradd --create-home demo
RUN chown demo .
USER demo

25
demo-image/README.md Normal file
View File

@@ -0,0 +1,25 @@
## Demo Dockerfile
This is a demo JupyterHub Docker image to help you get a quick overview of what
JupyterHub is and how it works.
It uses the SimpleLocalProcessSpawner to spawn new user servers and
DummyAuthenticator for authentication.
The DummyAuthenticator allows you to log in with any username & password and the
SimpleLocalProcessSpawner allows starting servers without having to create a
local user for each JupyterHub user.
### Important!
This should only be used for demo or testing purposes!
It shouldn't be used as a base image to build on.
### Try it
1. `cd` to the root of your jupyterhub repo.
2. Build the demo image with `docker build -t jupyterhub-demo demo-image`.
3. Run the demo image with `docker run -d -p 8000:8000 jupyterhub-demo`.
4. Visit http://localhost:8000 and login with any username and password
5. Happy demo-ing :tada:!

View File

@@ -0,0 +1,7 @@
# Configuration file for jupyterhub-demo
c = get_config()
# Use DummyAuthenticator and SimpleSpawner
c.JupyterHub.spawner_class = "simple"
c.JupyterHub.authenticator_class = "dummy"

9
dockerfiles/test.py Normal file
View File

@@ -0,0 +1,9 @@
import os
from jupyterhub._data import DATA_FILES_PATH
print(f"DATA_FILES_PATH={DATA_FILES_PATH}")
for sub_path in ("templates", "static/components", "static/css/style.min.css"):
path = os.path.join(DATA_FILES_PATH, sub_path)
assert os.path.exists(path), path

View File

@@ -48,6 +48,7 @@ help:
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
@echo " coverage to run coverage check of the documentation (if enabled)"
@echo " spelling to run spell check on documentation"
@echo " metrics to generate documentation for metrics by inspecting the source code"
clean:
rm -rf $(BUILDDIR)/*
@@ -60,7 +61,12 @@ rest-api: source/_static/rest-api/index.html
source/_static/rest-api/index.html: rest-api.yml node_modules
npm run rest-api
html: rest-api
metrics: source/reference/metrics.rst
source/reference/metrics.rst: generate-metrics.py
python3 generate-metrics.py
html: rest-api metrics
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."

View File

@@ -1,20 +0,0 @@
# ReadTheDocs uses the `environment.yaml` so make sure to update that as well
# if you change the dependencies of JupyterHub in the various `requirements.txt`
name: jhub_docs
channels:
- conda-forge
dependencies:
- pip
- nodejs
- python=3.6
- alembic
- jinja2
- pamela
- recommonmark==0.6.0
- requests
- sqlalchemy>=1
- tornado>=5.0
- traitlets>=4.1
- sphinx>=1.7
- pip:
- -r requirements.txt

57
docs/generate-metrics.py Normal file
View File

@@ -0,0 +1,57 @@
import os
from os.path import join
from pytablewriter import RstSimpleTableWriter
from pytablewriter.style import Style
import jupyterhub.metrics
HERE = os.path.abspath(os.path.dirname(__file__))
class Generator:
@classmethod
def create_writer(cls, table_name, headers, values):
writer = RstSimpleTableWriter()
writer.table_name = table_name
writer.headers = headers
writer.value_matrix = values
writer.margin = 1
[writer.set_style(header, Style(align="center")) for header in headers]
return writer
def _parse_metrics(self):
table_rows = []
for name in dir(jupyterhub.metrics):
obj = getattr(jupyterhub.metrics, name)
if obj.__class__.__module__.startswith('prometheus_client.'):
for metric in obj.describe():
table_rows.append([metric.type, metric.name, metric.documentation])
return table_rows
def prometheus_metrics(self):
generated_directory = f"{HERE}/source/reference"
if not os.path.exists(generated_directory):
os.makedirs(generated_directory)
filename = f"{generated_directory}/metrics.rst"
table_name = ""
headers = ["Type", "Name", "Description"]
values = self._parse_metrics()
writer = self.create_writer(table_name, headers, values)
title = "List of Prometheus Metrics"
underline = "============================"
content = f"{title}\n{underline}\n{writer.dumps()}"
with open(filename, 'w') as f:
f.write(content)
print(f"Generated {filename}.")
def main():
doc_generator = Generator()
doc_generator.prometheus_metrics()
if __name__ == "__main__":
main()

View File

@@ -1,10 +1,12 @@
# ReadTheDocs uses the `environment.yaml` so make sure to update that as well
# if you change this file
-r ../requirements.txt
alabaster_jupyterhub
autodoc-traits
git+https://github.com/pandas-dev/pandas-sphinx-theme.git@master
recommonmark==0.5.0
# Temporary fix of #3021. Revert back to released autodoc-traits when
# 0.1.0 released.
https://github.com/jupyterhub/autodoc-traits/archive/75885ee24636efbfebfceed1043459715049cd84.zip
pydata-sphinx-theme
pytablewriter>=0.56
recommonmark>=0.6
sphinx-copybutton
sphinx-jsonschema
sphinx>=1.7

View File

@@ -3,7 +3,7 @@ swagger: '2.0'
info:
title: JupyterHub
description: The REST API for JupyterHub
version: 0.9.0dev
version: 1.2.0dev
license:
name: BSD-3-Clause
schemes:
@@ -248,10 +248,13 @@ paths:
when spawning via the API instead of spawn form.
The structure of the options
will depend on the Spawner's configuration.
The body itself will be available as `user_options` for the
Spawner.
in: body
required: false
schema:
type: object
responses:
'201':
description: The user's notebook server has started
@@ -280,7 +283,10 @@ paths:
required: true
type: string
- name: server_name
description: name given to a named-server
description: |
name given to a named-server.
Note that depending on your JupyterHub infrastructure there are chracterter size limitation to `server_name`. Default spawner with K8s pod will not allow Jupyter Notebooks to be spawned with a name that contains more than 253 characters (keep in mind that the pod will be spawned with extra characters to identify the user and hub).
in: path
required: true
type: string
@@ -312,14 +318,18 @@ paths:
in: path
required: true
type: string
- name: remove
description: |
Whether to fully remove the server, rather than just stop it.
Removing a server deletes things like the state of the stopped server.
- name: body
in: body
required: false
schema:
type: object
properties:
remove:
type: boolean
description: |
Whether to fully remove the server, rather than just stop it.
Removing a server deletes things like the state of the stopped server.
Default: false.
responses:
'204':
description: The user's notebook named-server has stopped
@@ -795,6 +805,9 @@ definitions:
state:
type: object
description: Arbitrary internal state from this server's spawner. Only available on the hub's users list or get-user-by-name method, and only if a hub admin. None otherwise.
user_options:
type: object
description: User specified options for the user's spawned instance of a single-user server.
Group:
type: object
properties:

View File

@@ -1,106 +1,4 @@
div#helm-chart-schema h2,
div#helm-chart-schema h3,
div#helm-chart-schema h4,
div#helm-chart-schema h5,
div#helm-chart-schema h6 {
font-family: courier new;
}
h3, h3 ~ * {
margin-left: 3% !important;
}
h4, h4 ~ * {
margin-left: 6% !important;
}
h5, h5 ~ * {
margin-left: 9% !important;
}
h6, h6 ~ * {
margin-left: 12% !important;
}
h7, h7 ~ * {
margin-left: 15% !important;
}
img.logo {
width:100%
}
.right-next {
float: right;
max-width: 45%;
overflow: auto;
text-overflow: ellipsis;
white-space: nowrap;
}
.right-next::after{
content: ' »';
}
.left-prev {
float: left;
max-width: 45%;
overflow: auto;
text-overflow: ellipsis;
white-space: nowrap;
}
.left-prev::before{
content: '« ';
}
.prev-next-bottom {
margin-top: 3em;
}
.prev-next-top {
margin-bottom: 1em;
}
/* Sidebar TOC and headers */
div.sphinxsidebarwrapper div {
margin-bottom: .8em;
}
div.sphinxsidebar h3 {
font-size: 1.3em;
padding-top: 0px;
font-weight: 800;
margin-left: 0px !important;
}
div.sphinxsidebar p.caption {
font-size: 1.2em;
margin-bottom: 0px;
margin-left: 0px !important;
font-weight: 900;
color: #767676;
}
div.sphinxsidebar ul {
font-size: .8em;
margin-top: 0px;
padding-left: 3%;
margin-left: 0px !important;
}
div.relations ul {
font-size: 1em;
margin-left: 0px !important;
}
div#searchbox form {
margin-left: 0px !important;
}
/* body elements */
.toctree-wrapper span.caption-text {
color: #767676;
font-style: italic;
font-weight: 300;
/* Added to avoid logo being too squeezed */
.navbar-brand {
height: 4rem !important;
}

View File

@@ -1,16 +0,0 @@
{# Custom template for navigation.html
alabaster theme does not provide blocks for titles to
be overridden so this custom theme handles title and
toctree for sidebar
#}
<h3>{{ _('Table of Contents') }}</h3>
{{ toctree(includehidden=theme_sidebar_includehidden, collapse=theme_sidebar_collapse) }}
{% if theme_extra_nav_links %}
<hr />
<ul>
{% for text, uri in theme_extra_nav_links.items() %}
<li class="toctree-l1"><a href="{{ uri }}">{{ text }}</a></li>
{% endfor %}
</ul>
{% endif %}

View File

@@ -1,17 +0,0 @@
{# Custom template for relations.html
alabaster theme does not provide previous/next page by default
#}
<div class="relations">
<h3>Navigation</h3>
<ul>
<li><a href="{{ pathto(master_doc) }}">Documentation Home</a><ul>
{%- if prev %}
<li><a href="{{ prev.link|e }}" title="Previous">Previous topic</a></li>
{%- endif %}
{%- if next %}
<li><a href="{{ next.link|e }}" title="Next">Next topic</a></li>
{%- endif %}
</ul>
</ul>
</div>

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
#
import os
import shlex
import sys
# Set paths
@@ -20,10 +19,9 @@ extensions = [
'autodoc_traits',
'sphinx_copybutton',
'sphinx-jsonschema',
'recommonmark',
]
templates_path = ['_templates']
# The master toctree document.
master_doc = 'index'
@@ -59,22 +57,74 @@ default_role = 'literal'
import recommonmark
from recommonmark.transform import AutoStructify
# -- Config -------------------------------------------------------------
from jupyterhub.app import JupyterHub
from docutils import nodes
from sphinx.directives.other import SphinxDirective
from contextlib import redirect_stdout
from io import StringIO
# create a temp instance of JupyterHub just to get the output of the generate-config
# and help --all commands.
jupyterhub_app = JupyterHub()
class ConfigDirective(SphinxDirective):
"""Generate the configuration file output for use in the documentation."""
has_content = False
required_arguments = 0
optional_arguments = 0
final_argument_whitespace = False
option_spec = {}
def run(self):
# The generated configuration file for this version
generated_config = jupyterhub_app.generate_config_file()
# post-process output
home_dir = os.environ['HOME']
generated_config = generated_config.replace(home_dir, '$HOME', 1)
par = nodes.literal_block(text=generated_config)
return [par]
class HelpAllDirective(SphinxDirective):
"""Print the output of jupyterhub help --all for use in the documentation."""
has_content = False
required_arguments = 0
optional_arguments = 0
final_argument_whitespace = False
option_spec = {}
def run(self):
# The output of the help command for this version
buffer = StringIO()
with redirect_stdout(buffer):
jupyterhub_app.print_help('--help-all')
all_help = buffer.getvalue()
# post-process output
home_dir = os.environ['HOME']
all_help = all_help.replace(home_dir, '$HOME', 1)
par = nodes.literal_block(text=all_help)
return [par]
def setup(app):
app.add_config_value('recommonmark_config', {'enable_eval_rst': True}, True)
app.add_stylesheet('custom.css')
app.add_css_file('custom.css')
app.add_transform(AutoStructify)
app.add_directive('jupyterhub-generate-config', ConfigDirective)
app.add_directive('jupyterhub-help-all', HelpAllDirective)
source_parsers = {'.md': 'recommonmark.parser.CommonMarkParser'}
source_suffix = ['.rst', '.md']
# source_encoding = 'utf-8-sig'
# -- Options for HTML output ----------------------------------------------
# The theme to use for HTML and HTML Help pages.
html_theme = 'pandas_sphinx_theme'
html_theme = 'pydata_sphinx_theme'
html_logo = '_static/images/logo/logo.png'
html_favicon = '_static/images/logo/favicon.ico'
@@ -166,10 +216,10 @@ intersphinx_mapping = {'https://docs.python.org/3/': None}
on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
if on_rtd:
# readthedocs.org uses their theme by default, so no need to specify it
# build rest-api, since RTD doesn't run make
# build both metrics and rest-api, since RTD doesn't run make
from subprocess import check_call as sh
sh(['make', 'rest-api'], cwd=docs)
sh(['make', 'metrics', 'rest-api'], cwd=docs)
# -- Spell checking -------------------------------------------------------

View File

@@ -83,7 +83,6 @@ these will be moved at a future review of the roadmap.
- (prometheus?) API for resource monitoring
- tracking activity on single-user servers instead of the proxy
- notes and activity tracking per API token
- UI for managing named servers
### Later

View File

@@ -45,6 +45,12 @@ When developing JupyterHub, you need to make changes to the code & see
their effects quickly. You need to do a developer install to make that
happen.
.. note:: This guide does not attempt to dictate *how* development
environements should be isolated since that is a personal preference and can
be achieved in many ways, for example `tox`, `conda`, `docker`, etc. See this
`forum thread <https://discourse.jupyter.org/t/thoughts-on-using-tox/3497>`_ for
a more detailed discussion.
1. Clone the `JupyterHub git repository <https://github.com/jupyterhub/jupyterhub>`_
to your computer.
@@ -93,7 +99,14 @@ happen.
python3 -m pip install -r dev-requirements.txt
python3 -m pip install -r requirements.txt
5. Install the development version of JupyterHub. This lets you edit
5. Setup a database.
The default database engine is ``sqlite`` so if you are just trying
to get up and running quickly for local development that should be
available via `python <https://docs.python.org/3.5/library/sqlite3.html>`__.
See :doc:`/reference/database` for details on other supported databases.
6. Install the development version of JupyterHub. This lets you edit
JupyterHub code in a text editor & restart the JupyterHub process to
see your code changes immediately.
@@ -101,13 +114,13 @@ happen.
python3 -m pip install --editable .
6. You are now ready to start JupyterHub!
7. You are now ready to start JupyterHub!
.. code:: bash
jupyterhub
7. You can access JupyterHub from your browser at
8. You can access JupyterHub from your browser at
``http://localhost:8000`` now.
Happy developing!

View File

@@ -64,5 +64,5 @@ Troubleshooting Test Failures
All the tests are failing
-------------------------
Make sure you have completed all the steps in :ref:`contributing/setup` sucessfully, and
Make sure you have completed all the steps in :ref:`contributing/setup` successfully, and
can launch ``jupyterhub`` from the terminal.

View File

@@ -4,23 +4,23 @@ The default Authenticator uses [PAM][] to authenticate system users with
their username and password. With the default Authenticator, any user
with an account and password on the system will be allowed to login.
## Create a whitelist of users
## Create a set of allowed users
You can restrict which users are allowed to login with a whitelist,
`Authenticator.whitelist`:
You can restrict which users are allowed to login with a set,
`Authenticator.allowed_users`:
```python
c.Authenticator.whitelist = {'mal', 'zoe', 'inara', 'kaylee'}
c.Authenticator.allowed_users = {'mal', 'zoe', 'inara', 'kaylee'}
```
Users in the whitelist are added to the Hub database when the Hub is
Users in the `allowed_users` set are added to the Hub database when the Hub is
started.
## Configure admins (`admin_users`)
Admin users of JupyterHub, `admin_users`, can add and remove users from
the user `whitelist`. `admin_users` can take actions on other users'
the user `allowed_users` set. `admin_users` can take actions on other users'
behalf, such as stopping and restarting their servers.
A set of initial admin users, `admin_users` can configured be as follows:
@@ -28,7 +28,7 @@ A set of initial admin users, `admin_users` can configured be as follows:
```python
c.Authenticator.admin_users = {'mal', 'zoe'}
```
Users in the admin list are automatically added to the user `whitelist`,
Users in the admin set are automatically added to the user `allowed_users` set,
if they are not already present.
Each authenticator may have different ways of determining whether a user is an
@@ -53,12 +53,12 @@ sure your users know if admin_access is enabled.**
Users can be added to and removed from the Hub via either the admin
panel or the REST API. When 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,
automatically added to the allowed users set and database. Restarting the Hub
will not require manually updating the allowed users set 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 allowed users set in your config file. You must also remove the user
from the Hub's database, either by deleting the user from JupyterHub's
admin page, or you can clear the `jupyterhub.sqlite` database and start
fresh.

View File

@@ -0,0 +1,36 @@
# Frequently asked questions
### How do I share links to notebooks?
In short, where you see `/user/name/notebooks/foo.ipynb` use `/hub/user-redirect/notebooks/foo.ipynb` (replace `/user/name` with `/hub/user-redirect`).
Sharing links to notebooks is a common activity,
and can look different based on what you mean.
Your first instinct might be to copy the URL you see in the browser,
e.g. `hub.jupyter.org/user/yourname/notebooks/coolthing.ipynb`.
However, let's break down what this URL means:
`hub.jupyter.org/user/yourname/` is the URL prefix handled by *your server*,
which means that sharing this URL is asking the person you share the link with
to come to *your server* and look at the exact same file.
In most circumstances, this is forbidden by permissions because the person you share with does not have access to your server.
What actually happens when someone visits this URL will depend on whether your server is running and other factors.
But what is our actual goal?
A typical situation is that you have some shared or common filesystem,
such that the same path corresponds to the same document
(either the exact same document or another copy of it).
Typically, what folks want when they do sharing like this
is for each visitor to open the same file *on their own server*,
so Breq would open `/user/breq/notebooks/foo.ipynb` and
Seivarden would open `/user/seivarden/notebooks/foo.ipynb`, etc.
JupyterHub has a special URL that does exactly this!
It's called `/hub/user-redirect/...` and after the visitor logs in,
So if you replace `/user/yourname` in your URL bar
with `/hub/user-redirect` any visitor should get the same
URL on their own server, rather than visiting yours.
In JupyterLab 2.0, this should also be the result of the "Copy Shareable Link"
action in the file browser.

View File

@@ -15,4 +15,5 @@ own JupyterHub.
authenticators-users-basics
spawners-basics
services-basics
faq
institutional-faq

View File

@@ -21,7 +21,7 @@ Here is a quick breakdown of these three tools:
* **The Jupyter Notebook** is a document specification (the `.ipynb`) file that interweaves
narrative text with code cells and their outputs. It is also a graphical interface
that allows users to edit these documents. There are also several other graphical interfaces
that allow users to edit the `.ipynb` format (nteract, Jupyer Lab, Google Colab, Kaggle, etc).
that allow users to edit the `.ipynb` format (nteract, Jupyter Lab, Google Colab, Kaggle, etc).
* **JupyterLab** is a flexible and extendible user interface for interactive computing. It
has several extensions that are tailored for using Jupyter Notebooks, as well as extensions
for other parts of the data science stack.

View File

@@ -80,6 +80,49 @@ To achieve this, simply omit the configuration settings
``c.JupyterHub.ssl_key`` and ``c.JupyterHub.ssl_cert``
(setting them to ``None`` does not have the same effect, and is an error).
.. _authentication-token:
Proxy authentication token
--------------------------
The Hub authenticates its requests to the Proxy using a secret token that
the Hub and Proxy agree upon. Note that this applies to the default
``ConfigurableHTTPProxy`` implementation. Not all proxy implementations
use an auth token.
The value of this token should be a random string (for example, generated by
``openssl rand -hex 32``). You can store it in the configuration file or an
environment variable
Generating and storing token in the configuration file
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
You can set the value in the configuration file, ``jupyterhub_config.py``:
.. code-block:: python
c.ConfigurableHTTPProxy.api_token = 'abc123...' # any random string
Generating and storing as an environment variable
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
You can pass this value of the proxy authentication token to the Hub and Proxy
using the ``CONFIGPROXY_AUTH_TOKEN`` environment variable:
.. code-block:: bash
export CONFIGPROXY_AUTH_TOKEN=$(openssl rand -hex 32)
This environment variable needs to be visible to the Hub and Proxy.
Default if token is not set
~~~~~~~~~~~~~~~~~~~~~~~~~~~
If you don't set the Proxy authentication token, the Hub will generate a random
key itself, which means that any time you restart the Hub you **must also
restart the Proxy**. If the proxy is a subprocess of the Hub, this should happen
automatically (this is the default configuration).
.. _cookie-secret:
Cookie secret
@@ -146,41 +189,73 @@ itself, ``jupyterhub_config.py``, as a binary string:
If the cookie secret value changes for the Hub, all single-user notebook
servers must also be restarted.
.. _cookies:
.. _authentication-token:
Cookies used by JupyterHub authentication
-----------------------------------------
Proxy authentication token
--------------------------
The following cookies are used by the Hub for handling user authentication.
The Hub authenticates its requests to the Proxy using a secret token that
the Hub and Proxy agree upon. The value of this string should be a random
string (for example, generated by ``openssl rand -hex 32``).
This section was created based on this post_ from Discourse.
Generating and storing token in the configuration file
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. _post: https://discourse.jupyter.org/t/how-to-force-re-login-for-users/1998/6
Or you can set the value in the configuration file, ``jupyterhub_config.py``:
jupyterhub-hub-login
~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
This is the login token used when visiting Hub-served pages that are
protected by authentication such as the main home, the spawn form, etc.
If this cookie is set, then the user is logged in.
c.JupyterHub.proxy_auth_token = '0bc02bede919e99a26de1e2a7a5aadfaf6228de836ec39a05a6c6942831d8fe5'
Resetting the Hub cookie secret effectively revokes this cookie.
Generating and storing as an environment variable
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
This cookie is restricted to the path ``/hub/``.
You can pass this value of the proxy authentication token to the Hub and Proxy
using the ``CONFIGPROXY_AUTH_TOKEN`` environment variable:
jupyterhub-user-<username>
~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: bash
This is the cookie used for authenticating with a single-user server.
It is set by the single-user server after OAuth with the Hub.
export CONFIGPROXY_AUTH_TOKEN=$(openssl rand -hex 32)
Effectively the same as ``jupyterhub-hub-login``, but for the
single-user server instead of the Hub. It contains an OAuth access token,
which is checked with the Hub to authenticate the browser.
This environment variable needs to be visible to the Hub and Proxy.
Each OAuth access token is associated with a session id (see ``jupyterhub-session-id`` section
below).
Default if token is not set
~~~~~~~~~~~~~~~~~~~~~~~~~~~
To avoid hitting the Hub on every request, the authentication response
is cached. And to avoid a stale cache the cache key is comprised of both
the token and session id.
If you don't set the Proxy authentication token, the Hub will generate a random
key itself, which means that any time you restart the Hub you **must also
restart the Proxy**. If the proxy is a subprocess of the Hub, this should happen
automatically (this is the default configuration).
Resetting the Hub cookie secret effectively revokes this cookie.
This cookie is restricted to the path ``/user/<username>``, so that
only the users server receives it.
jupyterhub-session-id
~~~~~~~~~~~~~~~~~~~~~
This is a random string, meaningless in itself, and the only cookie
shared by the Hub and single-user servers.
Its sole purpose is to coordinate logout of the multiple OAuth cookies.
This cookie is set to ``/`` so all endpoints can receive it, or clear it, etc.
jupyterhub-user-<username>-oauth-state
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
A short-lived cookie, used solely to store and validate OAuth state.
It is only set while OAuth between the single-user server and the Hub
is processing.
If you use your browser development tools, you should see this cookie
for a very brief moment before your are logged in,
with an expiration date shorter than ``jupyterhub-hub-login`` or
``jupyterhub-user-<username>``.
This cookie should not exist after you have successfully logged in.
This cookie is restricted to the path ``/user/<username>``, so that only
the users server receives it.

View File

@@ -5,7 +5,7 @@ that interacts with the Hub's REST API. A Service may perform a specific
or action or task. For example, shutting down individuals' single user
notebook servers that have been idle for some time is a good example of
a task that could be automated by a Service. Let's look at how the
[cull_idle_servers][] script can be used as a Service.
[jupyterhub_idle_culler][] script can be used as a Service.
## Real-world example to cull idle servers
@@ -15,11 +15,11 @@ document will:
- explain some basic information about API tokens
- clarify that API tokens can be used to authenticate to
single-user servers as of [version 0.8.0](../changelog)
- show how the [cull_idle_servers][] script can be:
- show how the [jupyterhub_idle_culler][] script can be:
- used in a Hub-managed service
- run as a standalone script
Both examples for `cull_idle_servers` will communicate tasks to the
Both examples for `jupyterhub_idle_culler` will communicate tasks to the
Hub via the REST API.
## API Token basics
@@ -78,17 +78,23 @@ single-user servers, and only cookies can be used for authentication.
0.8 supports using JupyterHub API tokens to authenticate to single-user
servers.
## Configure `cull-idle` to run as a Hub-Managed Service
## Configure the idle culler to run as a Hub-Managed Service
Install the idle culler:
```
pip install jupyterhub-idle-culler
```
In `jupyterhub_config.py`, add the following dictionary for the
`cull-idle` Service to the `c.JupyterHub.services` list:
`idle-culler` Service to the `c.JupyterHub.services` list:
```python
c.JupyterHub.services = [
{
'name': 'cull-idle',
'name': 'idle-culler',
'admin': True,
'command': [sys.executable, 'cull_idle_servers.py', '--timeout=3600'],
'command': [sys.executable, '-m', 'jupyterhub_idle_culler', '--timeout=3600'],
}
]
```
@@ -101,21 +107,21 @@ where:
## Run `cull-idle` manually as a standalone script
Now you can run your script, i.e. `cull_idle_servers`, by providing it
Now you can run your script by providing it
the API token and it will authenticate through the REST API to
interact with it.
This will run `cull-idle` manually. `cull-idle` can be run as a standalone
This will run the idle culler service manually. It can be run as a standalone
script anywhere with access to the Hub, and will periodically check for idle
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.
variable. Run `jupyterhub_idle_culler` manually.
```bash
export JUPYTERHUB_API_TOKEN='token'
python3 cull_idle_servers.py [--timeout=900] [--url=http://127.0.0.1:8081/hub/api]
python -m jupyterhub_idle_culler [--timeout=900] [--url=http://127.0.0.1:8081/hub/api]
```
[cull_idle_servers]: https://github.com/jupyterhub/jupyterhub/blob/master/examples/cull-idle/cull_idle_servers.py
[jupyterhub_idle_culler]: https://github.com/jupyterhub/jupyterhub-idle-culler

View File

@@ -3,11 +3,11 @@ JupyterHub
==========
`JupyterHub`_ is the best way to serve `Jupyter notebook`_ for multiple users.
It can be used in a classes of students, a corporate data science group or scientific
It can be used in a class of students, a corporate data science group or scientific
research group. It is a multi-user **Hub** that spawns, manages, and proxies multiple
instances of the single-user `Jupyter notebook`_ server.
To make life easier, JupyterHub have distributions. Be sure to
To make life easier, JupyterHub has distributions. Be sure to
take a look at them before continuing with the configuration of the broad
original system of `JupyterHub`_. Today, you can find two main cases:

View File

@@ -88,7 +88,7 @@ sudo apt install nodejs npm
Then install `configurable-http-proxy`:
```sh
npm install -g configurable-http-proxy
sudo npm install -g configurable-http-proxy
```
### Create the configuration for JupyterHub
@@ -206,7 +206,7 @@ sudo install -o root -g root -m 644 conda.gpg /etc/apt/trusted.gpg.d/
Add Debian repo
```sh
sudo echo "deb [arch=amd64] https://repo.anaconda.com/pkgs/misc/debrepo/conda stable main" > /etc/apt/sources.list.d/conda.list
echo "deb [arch=amd64] https://repo.anaconda.com/pkgs/misc/debrepo/conda stable main" | sudo tee /etc/apt/sources.list.d/conda.list
```
Install conda
@@ -278,7 +278,7 @@ this way - instead, use a reverse proxy running on standard HTTP/HTTPS ports.
> i.e. not protected within an institutional intranet or private home/office network. You should set up a firewall and
> HTTPS encryption, which is outside of the scope of this guide. For HTTPS consider using [LetsEncrypt](https://letsencrypt.org/)
> or setting up a [self-signed certificate](https://www.digitalocean.com/community/tutorials/how-to-create-a-self-signed-ssl-certificate-for-nginx-in-ubuntu-18-04).
> Firewalls may be set up using `ufs` or `firewalld` and combined with `fail2ban`.
> Firewalls may be set up using `ufw` or `firewalld` and combined with `fail2ban`.
### Using Nginx
Nginx is a mature and established web server and reverse proxy and is easy to install using `sudo apt install nginx`.
@@ -316,6 +316,15 @@ Add the following snippet to your nginx configuration file (e.g. `/etc/nginx/sit
}
```
Also add this snippet before the *server* block:
```
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
```
Nginx will not run if there are errors in the configuration, check your configuration using:
```sh

View File

@@ -26,6 +26,10 @@ Before installing JupyterHub, you will need:
The `nodejs-legacy` package installs the `node` executable and is currently
required for npm to work on Debian/Ubuntu.
- A [pluggable authentication module (PAM)](https://en.wikipedia.org/wiki/Pluggable_authentication_module)
to use the [default Authenticator](./getting-started/authenticators-users-basics.md).
PAM is often available by default on most distributions, if this is not the case it can be installed by
using the operating system's package manager.
- TLS certificate and key for HTTPS communication
- Domain name

View File

@@ -52,7 +52,7 @@ c.GitHubOAuthenticator.oauth_callback_url = os.environ['OAUTH_CALLBACK_URL']
c.LocalAuthenticator.create_system_users = True
# specify users and admin
c.Authenticator.whitelist = {'rgbkrk', 'minrk', 'jhamrick'}
c.Authenticator.allowed_users = {'rgbkrk', 'minrk', 'jhamrick'}
c.Authenticator.admin_users = {'jhamrick', 'rgbkrk'}
# uses the default spawner

View File

@@ -83,8 +83,11 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# websocket headers
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
}
# Managing requests to verify letsencrypt host
@@ -139,6 +142,20 @@ Now restart `nginx`, restart the JupyterHub, and enjoy accessing
`https://HUB.DOMAIN.TLD` while serving other content securely on
`https://NO_HUB.DOMAIN.TLD`.
### SELinux permissions for nginx
On distributions with SELinux enabled (e.g. Fedora), one may encounter permission errors
when the nginx service is started.
We need to allow nginx to perform network relay and connect to the jupyterhub port. The
following commands do that:
```bash
semanage port -a -t http_port_t -p tcp 8000
setsebool -P httpd_can_network_relay 1
setsebool -P httpd_can_network_connect 1
```
Replace 8000 with the port the jupyterhub server is running from.
## Apache
@@ -199,8 +216,8 @@ In case of the need to run the jupyterhub under /jhub/ or other location please
httpd.conf amendments:
```bash
RewriteRule /jhub/(.*) ws://127.0.0.1:8000/jhub/$1 [P,L]
RewriteRule /jhub/(.*) http://127.0.0.1:8000/jhub/$1 [P,L]
RewriteRule /jhub/(.*) ws://127.0.0.1:8000/jhub/$1 [NE.P,L]
RewriteRule /jhub/(.*) http://127.0.0.1:8000/jhub/$1 [NE,P,L]
ProxyPass /jhub/ http://127.0.0.1:8000/jhub/
ProxyPassReverse /jhub/ http://127.0.0.1:8000/jhub/

View File

@@ -0,0 +1,30 @@
==============================
Configuration Reference
==============================
.. important::
Make sure the version of JupyterHub for this documentation matches your
installation version, as the output of this command may change between versions.
JupyterHub configuration
------------------------
As explained in the `Configuration Basics <../getting-started/config-basics.html#generate-a-default-config-file>`_
section, the ``jupyterhub_config.py`` can be automatically generated via
.. code-block:: bash
jupyterhub --generate-config
The following contains the output of that command for reference.
.. jupyterhub-generate-config::
JupyterHub help command output
------------------------------
This section contains the output of the command ``jupyterhub --help-all``.
.. jupyterhub-help-all::

View File

@@ -57,7 +57,7 @@ To do this we add to `/etc/sudoers` (use `visudo` for safe editing of sudoers):
For example:
```bash
# comma-separated whitelist of users that can spawn single-user servers
# comma-separated list of users that can spawn single-user servers
# this should include all of your Hub users
Runas_Alias JUPYTER_USERS = rhea, zoe, wash
@@ -120,6 +120,11 @@ the shadow password database.
### Shadow group (Linux)
**Note:** On Fedora based distributions there is no clear way to configure
the PAM database to allow sufficient access for authenticating with the target user's password
from JupyterHub. As a workaround we recommend use an
[alternative authentication method](https://github.com/jupyterhub/jupyterhub/wiki/Authenticators).
```bash
$ ls -l /etc/shadow
-rw-r----- 1 root shadow 2197 Jul 21 13:41 shadow

View File

@@ -16,6 +16,7 @@ what happens under-the-hood when you deploy and configure your JupyterHub.
proxy
separate-proxy
rest
monitoring
database
templates
../events/index
@@ -24,3 +25,4 @@ what happens under-the-hood when you deploy and configure your JupyterHub.
config-ghoauth
config-proxy
config-sudo
config-reference

View File

@@ -0,0 +1,20 @@
Monitoring
==========
This section covers details on monitoring the state of your JupyterHub installation.
JupyterHub expose the ``/metrics`` endpoint that returns text describing its current
operational state formatted in a way `Prometheus <https://prometheus.io/docs/introduction/overview/>`_ understands.
Prometheus is a separate open source tool that can be configured to repeatedly poll
JupyterHub's ``/metrics`` endpoint to parse and save its current state.
By doing so, Prometheus can describe JupyterHub's evolving state over time.
This evolving state can then be accessed through Prometheus that expose its underlying
storage to those allowed to access it, and be presented with dashboards by a
tool like `Grafana <https://grafana.com/docs/grafana/latest/getting-started/what-is-grafana/>`_.
.. toctree::
:maxdepth: 2
metrics

View File

@@ -57,6 +57,9 @@ generating an API token is available from the JupyterHub user interface:
## Add API tokens to the config file
**This is deprecated. We are in no rush to remove this feature,
but please consider if service tokens are right for you.**
You may also add a dictionary of API tokens and usernames to the hub's
configuration file, `jupyterhub_config.py` (note that
the **key** is the 'secret-token' while the **value** is the 'username'):
@@ -67,6 +70,41 @@ c.JupyterHub.api_tokens = {
}
```
### Updating to admin services
The `api_tokens` configuration has been softly deprecated since the introduction of services.
We have no plans to remove it,
but users are encouraged to use service configuration instead.
If you have been using `api_tokens` to create an admin user
and a token for that user to perform some automations,
the services mechanism may be a better fit.
If you have the following configuration:
```python
c.JupyterHub.admin_users = {"service-admin",}
c.JupyterHub.api_tokens = {
"secret-token": "service-admin",
}
```
This can be updated to create an admin service, with the following configuration:
```python
c.JupyterHub.services = [
{
"name": "service-token",
"admin": True,
"api_token": "secret-token",
},
]
```
The token will have the same admin permissions,
but there will no longer be a user account created to house it.
The main noticeable difference is that there will be no notebook server associated with the account
and the service will not show up in the various user list pages and APIs.
## Make an API request
To authenticate your requests, pass the API token in the request's

View File

@@ -151,6 +151,8 @@ c.JupyterHub.services = [
{
'name': 'my-web-service',
'url': 'https://10.0.1.1:1984',
# any secret >8 characters, you'll use api_token to
# authenticate api requests to the hub from your service
'api_token': 'super-secret',
}
]
@@ -313,7 +315,7 @@ class MyHandler(HubAuthenticated, web.RequestHandler):
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
If you want to limit user access, you can specify allowed users through either the
`.hub_users` attribute or `.hub_groups`. These are sets that check against the
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
@@ -331,7 +333,9 @@ 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.
This request must be authenticated with a Hub API token in the `Authorization` header,
for example using the `api_token` from your [external service's configuration](#externally-managed-services).
For example, with [requests][]:
```python
@@ -360,7 +364,7 @@ and taking note of the following process:
An example of using an Externally-Managed Service and authentication is
in [nbviewer README][nbviewer example] section on securing the notebook viewer,
and an example of its configuration is found [here](https://github.com/jupyter/nbviewer/blob/master/nbviewer/providers/base.py#L94).
and an example of its configuration is found [here](https://github.com/jupyter/nbviewer/blob/ed942b10a52b6259099e2dd687930871dc8aac22/nbviewer/providers/base.py#L95).
nbviewer can also be run as a Hub-Managed Service as described [nbviewer README][nbviewer example]
section on securing the notebook viewer.

View File

@@ -27,8 +27,8 @@ Some examples include:
servers using batch systems
- [YarnSpawner](https://github.com/jupyterhub/yarnspawner) for spawning notebook
servers in YARN containers on a Hadoop cluster
- [RemoteSpawner](https://github.com/zonca/remotespawner) to spawn notebooks
and a remote server and tunnel the port via SSH
- [SSHSpawner](https://github.com/NERSC/sshspawner) to spawn notebooks
on a remote server using SSH
## Spawner control methods

View File

@@ -7,8 +7,8 @@ problem and how to resolve it.
[*Behavior*](#behavior)
- JupyterHub proxy fails to start
- sudospawner fails to run
- What is the default behavior when none of the lists (admin, whitelist,
group whitelist) are set?
- What is the default behavior when none of the lists (admin, allowed,
allowed groups) are set?
- JupyterHub Docker container not accessible at localhost
[*Errors*](#errors)
@@ -55,12 +55,12 @@ or add:
to the config file, `jupyterhub_config.py`.
### What is the default behavior when none of the lists (admin, whitelist, group whitelist) are set?
### What is the default behavior when none of the lists (admin, allowed, allowed groups) are set?
When nothing is given for these lists, there will be no admins, and all users
who can authenticate on the system (i.e. all the unix users on the server with
a password) will be allowed to start a server. The whitelist lets you limit
this to a particular set of users, and the admin_users lets you specify who
a password) will be allowed to start a server. The allowed username set lets you limit
this to a particular set of users, and admin_users lets you specify who
among them may use the admin interface (not necessary, unless you need to do
things like inspect other users' servers, or modify the user list at runtime).
@@ -75,6 +75,50 @@ tell Jupyterhub to start at `0.0.0.0` which is visible to everyone. Try this
command:
`docker run -p 8000:8000 -d --name jupyterhub jupyterhub/jupyterhub jupyterhub --ip 0.0.0.0 --port 8000`
### How can I kill ports from JupyterHub managed services that have been orphaned?
I started JupyterHub + nbgrader on the same host without containers. When I try to restart JupyterHub + nbgrader with this configuration, errors appear that the service accounts cannot start because the ports are being used.
How can I kill the processes that are using these ports?
Run the following command:
sudo kill -9 $(sudo lsof -t -i:<service_port>)
Where `<service_port>` is the port used by the nbgrader course service. This configuration is specified in `jupyterhub_config.py`.
### Why am I getting a Spawn failed error message?
After successfully logging in to JupyterHub with a compatible authenticators, I get a 'Spawn failed' error message in the browser. The JupyterHub logs have `jupyterhub KeyError: "getpwnam(): name not found: <my_user_name>`.
This issue occurs when the authenticator requires a local system user to exist. In these cases, you need to use a spawner
that does not require an existing system user account, such as `DockerSpawner` or `KubeSpawner`.
### How can I run JupyterHub with sudo but use my current env vars and virtualenv location?
When launching JupyterHub with `sudo jupyterhub` I get import errors and my environment variables don't work.
When launching services with `sudo ...` the shell won't have the same environment variables or `PATH`s in place. The most direct way to solve this issue is to use the full path to your python environment and add environment variables. For example:
```bash
sudo MY_ENV=abc123 \
/home/foo/venv/bin/python3 \
/srv/jupyterhub/jupyterhub
```
### How can I view the logs for JupyterHub or the user's Notebook servers when using the DockerSpawner?
Use `docker logs <container>` where `<container>` is the container name defined within `docker-compose.yml`. For example, to view the logs of the JupyterHub container use:
docker logs hub
By default, the user's notebook server is named `jupyter-<username>` where `username` is the user's username within JupyterHub's db. So if you wanted to see the logs for user `foo` you would use:
docker logs jupyter-foo
You can also tail logs to view them in real time using the `-f` option:
docker logs -f hub
## Errors
@@ -108,7 +152,7 @@ You should see a similar 200 message, as above, in the Hub log when you first
visit your single-user notebook server. If you don't see this message in the log, it
may mean that your single-user notebook server isn't connecting to your Hub.
If you see 403 (forbidden) like this, it's a token problem:
If you see 403 (forbidden) like this, it's likely a token problem:
```
403 GET /hub/api/authorizations/cookie/jupyterhub-token-name/[secret] (@10.0.1.4) 4.14ms
@@ -152,6 +196,42 @@ After this, when you start your server via JupyterHub, it will build a
new container. If this was the underlying cause of the issue, you should see
your server again.
##### Proxy settings (403 GET)
When your whole JupyterHub sits behind a organization proxy (*not* a reverse proxy like NGINX as part of your setup and *not* the configurable-http-proxy) the environment variables `HTTP_PROXY`, `HTTPS_PROXY`, `http_proxy` and `https_proxy` might be set. This confuses the jupyterhub-singleuser servers: When connecting to the Hub for authorization they connect via the proxy instead of directly connecting to the Hub on localhost. The proxy might deny the request (403 GET). This results in the singleuser server thinking it has a wrong auth token. To circumvent this you should add `<hub_url>,<hub_ip>,localhost,127.0.0.1` to the environment variables `NO_PROXY` and `no_proxy`.
### Launching Jupyter Notebooks to run as an externally managed JupyterHub service with the `jupyterhub-singleuser` command returns a `JUPYTERHUB_API_TOKEN` error
[JupyterHub services](https://jupyterhub.readthedocs.io/en/stable/reference/services.html) allow processes to interact with JupyterHub's REST API. Example use-cases include:
* **Secure Testing**: provide a canonical Jupyter Notebook for testing production data to reduce the number of entry points into production systems.
* **Grading Assignments**: provide access to shared Jupyter Notebooks that may be used for management tasks such grading assignments.
* **Private Dashboards**: share dashboards with certain group members.
If possible, try to run the Jupyter Notebook as an externally managed service with one of the provided [jupyter/docker-stacks](https://github.com/jupyter/docker-stacks).
Standard JupyterHub installations include a [jupyterhub-singleuser](https://github.com/jupyterhub/jupyterhub/blob/9fdab027daa32c9017845572ad9d5ba1722dbc53/setup.py#L116) command which is built from the `jupyterhub.singleuser:main` method. The `jupyterhub-singleuser` command is the default command when JupyterHub launches single-user Jupyter Notebooks. One of the goals of this command is to make sure the version of JupyterHub installed within the Jupyter Notebook coincides with the version of the JupyterHub server itself.
If you launch a Jupyter Notebook with the `jupyterhub-singleuser` command directly from the command line the Jupyter Notebook won't have access to the `JUPYTERHUB_API_TOKEN` and will return:
```
JUPYTERHUB_API_TOKEN env is required to run jupyterhub-singleuser.
Did you launch it manually?
```
If you plan on testing `jupyterhub-singleuser` independently from JupyterHub, then you can set the api token environment variable. For example, if were to run the single-user Jupyter Notebook on the host, then:
export JUPYTERHUB_API_TOKEN=my_secret_token
jupyterhub-singleuser
With a docker container, pass in the environment variable with the run command:
docker run -d \
-p 8888:8888 \
-e JUPYTERHUB_API_TOKEN=my_secret_token \
jupyter/datascience-notebook:latest
[This example](https://github.com/jupyterhub/jupyterhub/tree/master/examples/service-notebook/external) demonstrates how to combine the use of the `jupyterhub-singleuser` environment variables when launching a Notebook as an externally managed service.
## How do I...?
@@ -193,7 +273,7 @@ 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).
See also [JupyterHub SSL encryption](./getting-started/security-basics.html#ssl-encryption).
### Install JupyterHub without a network connection
@@ -252,8 +332,7 @@ notebook servers to default to JupyterLab:
### How do I set up JupyterHub for a workshop (when users are not known ahead of time)?
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.
2. 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.
@@ -281,7 +360,6 @@ Or use syslog:
jupyterhub | logger -t jupyterhub
## Troubleshooting commands
The following commands provide additional detail about installed packages,

View File

@@ -1,41 +1,4 @@
# `cull-idle` Example
# idle-culler example
The `cull_idle_servers.py` file provides a script to cull and shut down idle
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': [sys.executable, 'cull_idle_servers.py', '--timeout=3600'],
}
]
```
where:
- `'admin': True` indicates that the Service has 'admin' permissions, and
- `'command'` indicates that the Service will be managed by the Hub.
## Run `cull-idle` manually as a standalone script
This will run `cull-idle` manually. `cull-idle` can be run as a standalone
script anywhere with access to the Hub, and will periodically check for idle
servers and shut them down via the Hub's REST API. In order to shutdown the
servers, the token given to cull-idle must have admin privileges.
Generate an API token and store it in the `JUPYTERHUB_API_TOKEN` environment
variable. Run `cull_idle_servers.py` manually.
```bash
export JUPYTERHUB_API_TOKEN=$(jupyterhub token)
python3 cull_idle_servers.py [--timeout=900] [--url=http://127.0.0.1:8081/hub/api]
```
The idle culler has been moved to its own repository at
[jupyterhub/jupyterhub-idle-culler](https://github.com/jupyterhub/jupyterhub-idle-culler).

View File

@@ -1,401 +0,0 @@
#!/usr/bin/env python3
"""script to monitor and cull idle single-user servers
Caveats:
last_activity is not updated with high frequency,
so cull timeout should be greater than the sum of:
- single-user websocket ping interval (default: 30s)
- JupyterHub.last_activity_interval (default: 5 minutes)
You can run this as a service managed by JupyterHub with this in your config::
c.JupyterHub.services = [
{
'name': 'cull-idle',
'admin': True,
'command': [sys.executable, 'cull_idle_servers.py', '--timeout=3600'],
}
]
Or run it manually by generating an API token and storing it in `JUPYTERHUB_API_TOKEN`:
export JUPYTERHUB_API_TOKEN=$(jupyterhub token)
python3 cull_idle_servers.py [--timeout=900] [--url=http://127.0.0.1:8081/hub/api]
This script uses the same ``--timeout`` and ``--max-age`` values for
culling users and users' servers. If you want a different value for
users and servers, you should add this script to the services list
twice, just with different ``name``s, different values, and one with
the ``--cull-users`` option.
"""
import json
import os
from datetime import datetime
from datetime import timezone
from functools import partial
try:
from urllib.parse import quote
except ImportError:
from urllib import quote
import dateutil.parser
from tornado.gen import coroutine, multi
from tornado.locks import Semaphore
from tornado.log import app_log
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
from tornado.ioloop import IOLoop, PeriodicCallback
from tornado.options import define, options, parse_command_line
def parse_date(date_string):
"""Parse a timestamp
If it doesn't have a timezone, assume utc
Returned datetime object will always be timezone-aware
"""
dt = dateutil.parser.parse(date_string)
if not dt.tzinfo:
# assume naïve timestamps are UTC
dt = dt.replace(tzinfo=timezone.utc)
return dt
def format_td(td):
"""
Nicely format a timedelta object
as HH:MM:SS
"""
if td is None:
return "unknown"
if isinstance(td, str):
return td
seconds = int(td.total_seconds())
h = seconds // 3600
seconds = seconds % 3600
m = seconds // 60
seconds = seconds % 60
return "{h:02}:{m:02}:{seconds:02}".format(h=h, m=m, seconds=seconds)
@coroutine
def cull_idle(
url, api_token, inactive_limit, cull_users=False, max_age=0, concurrency=10
):
"""Shutdown idle single-user servers
If cull_users, inactive *users* will be deleted as well.
"""
auth_header = {'Authorization': 'token %s' % api_token}
req = HTTPRequest(url=url + '/users', headers=auth_header)
now = datetime.now(timezone.utc)
client = AsyncHTTPClient()
if concurrency:
semaphore = Semaphore(concurrency)
@coroutine
def fetch(req):
"""client.fetch wrapped in a semaphore to limit concurrency"""
yield semaphore.acquire()
try:
return (yield client.fetch(req))
finally:
yield semaphore.release()
else:
fetch = client.fetch
resp = yield fetch(req)
users = json.loads(resp.body.decode('utf8', 'replace'))
futures = []
@coroutine
def handle_server(user, server_name, server, max_age, inactive_limit):
"""Handle (maybe) culling a single server
"server" is the entire server model from the API.
Returns True if server is now stopped (user removable),
False otherwise.
"""
log_name = user['name']
if server_name:
log_name = '%s/%s' % (user['name'], server_name)
if server.get('pending'):
app_log.warning(
"Not culling server %s with pending %s", log_name, server['pending']
)
return False
# jupyterhub < 0.9 defined 'server.url' once the server was ready
# as an *implicit* signal that the server was ready.
# 0.9 adds a dedicated, explicit 'ready' field.
# By current (0.9) definitions, servers that have no pending
# events and are not ready shouldn't be in the model,
# but let's check just to be safe.
if not server.get('ready', bool(server['url'])):
app_log.warning(
"Not culling not-ready not-pending server %s: %s", log_name, server
)
return False
if server.get('started'):
age = now - parse_date(server['started'])
else:
# started may be undefined on jupyterhub < 0.9
age = None
# check last activity
# last_activity can be None in 0.9
if server['last_activity']:
inactive = now - parse_date(server['last_activity'])
else:
# no activity yet, use start date
# last_activity may be None with jupyterhub 0.9,
# which introduces the 'started' field which is never None
# for running servers
inactive = age
# CUSTOM CULLING TEST CODE HERE
# Add in additional server tests here. Return False to mean "don't
# cull", True means "cull immediately", or, for example, update some
# other variables like inactive_limit.
#
# Here, server['state'] is the result of the get_state method
# on the spawner. This does *not* contain the below by
# default, you may have to modify your spawner to make this
# work. The `user` variable is the user model from the API.
#
# if server['state']['profile_name'] == 'unlimited'
# return False
# inactive_limit = server['state']['culltime']
should_cull = (
inactive is not None and inactive.total_seconds() >= inactive_limit
)
if should_cull:
app_log.info(
"Culling server %s (inactive for %s)", log_name, format_td(inactive)
)
if max_age and not should_cull:
# only check started if max_age is specified
# so that we can still be compatible with jupyterhub 0.8
# which doesn't define the 'started' field
if age is not None and age.total_seconds() >= max_age:
app_log.info(
"Culling server %s (age: %s, inactive for %s)",
log_name,
format_td(age),
format_td(inactive),
)
should_cull = True
if not should_cull:
app_log.debug(
"Not culling server %s (age: %s, inactive for %s)",
log_name,
format_td(age),
format_td(inactive),
)
return False
if server_name:
# culling a named server
delete_url = url + "/users/%s/servers/%s" % (
quote(user['name']),
quote(server['name']),
)
else:
delete_url = url + '/users/%s/server' % quote(user['name'])
req = HTTPRequest(url=delete_url, method='DELETE', headers=auth_header)
resp = yield fetch(req)
if resp.code == 202:
app_log.warning("Server %s is slow to stop", log_name)
# return False to prevent culling user with pending shutdowns
return False
return True
@coroutine
def handle_user(user):
"""Handle one user.
Create a list of their servers, and async exec them. Wait for
that to be done, and if all servers are stopped, possibly cull
the user.
"""
# shutdown servers first.
# Hub doesn't allow deleting users with running servers.
# jupyterhub 0.9 always provides a 'servers' model.
# 0.8 only does this when named servers are enabled.
if 'servers' in user:
servers = user['servers']
else:
# jupyterhub < 0.9 without named servers enabled.
# create servers dict with one entry for the default server
# from the user model.
# only if the server is running.
servers = {}
if user['server']:
servers[''] = {
'last_activity': user['last_activity'],
'pending': user['pending'],
'url': user['server'],
}
server_futures = [
handle_server(user, server_name, server, max_age, inactive_limit)
for server_name, server in servers.items()
]
results = yield multi(server_futures)
if not cull_users:
return
# some servers are still running, cannot cull users
still_alive = len(results) - sum(results)
if still_alive:
app_log.debug(
"Not culling user %s with %i servers still alive",
user['name'],
still_alive,
)
return False
should_cull = False
if user.get('created'):
age = now - parse_date(user['created'])
else:
# created may be undefined on jupyterhub < 0.9
age = None
# check last activity
# last_activity can be None in 0.9
if user['last_activity']:
inactive = now - parse_date(user['last_activity'])
else:
# no activity yet, use start date
# last_activity may be None with jupyterhub 0.9,
# which introduces the 'created' field which is never None
inactive = age
should_cull = (
inactive is not None and inactive.total_seconds() >= inactive_limit
)
if should_cull:
app_log.info("Culling user %s (inactive for %s)", user['name'], inactive)
if max_age and not should_cull:
# only check created if max_age is specified
# so that we can still be compatible with jupyterhub 0.8
# which doesn't define the 'started' field
if age is not None and age.total_seconds() >= max_age:
app_log.info(
"Culling user %s (age: %s, inactive for %s)",
user['name'],
format_td(age),
format_td(inactive),
)
should_cull = True
if not should_cull:
app_log.debug(
"Not culling user %s (created: %s, last active: %s)",
user['name'],
format_td(age),
format_td(inactive),
)
return False
req = HTTPRequest(
url=url + '/users/%s' % user['name'], method='DELETE', headers=auth_header
)
yield fetch(req)
return True
for user in users:
futures.append((user['name'], handle_user(user)))
for (name, f) in futures:
try:
result = yield f
except Exception:
app_log.exception("Error processing %s", name)
else:
if result:
app_log.debug("Finished culling %s", name)
if __name__ == '__main__':
define(
'url',
default=os.environ.get('JUPYTERHUB_API_URL'),
help="The JupyterHub API URL",
)
define('timeout', default=600, help="The idle timeout (in seconds)")
define(
'cull_every',
default=0,
help="The interval (in seconds) for checking for idle servers to cull",
)
define(
'max_age',
default=0,
help="The maximum age (in seconds) of servers that should be culled even if they are active",
)
define(
'cull_users',
default=False,
help="""Cull users in addition to servers.
This is for use in temporary-user cases such as tmpnb.""",
)
define(
'concurrency',
default=10,
help="""Limit the number of concurrent requests made to the Hub.
Deleting a lot of users at the same time can slow down the Hub,
so limit the number of API requests we have outstanding at any given time.
""",
)
parse_command_line()
if not options.cull_every:
options.cull_every = options.timeout // 2
api_token = os.environ['JUPYTERHUB_API_TOKEN']
try:
AsyncHTTPClient.configure("tornado.curl_httpclient.CurlAsyncHTTPClient")
except ImportError as e:
app_log.warning(
"Could not load pycurl: %s\n"
"pycurl is recommended if you have a large number of users.",
e,
)
loop = IOLoop.current()
cull = partial(
cull_idle,
url=options.url,
api_token=api_token,
inactive_limit=options.timeout,
cull_users=options.cull_users,
max_age=options.max_age,
concurrency=options.concurrency,
)
# schedule first cull immediately
# because PeriodicCallback doesn't start until the end of the first interval
loop.add_callback(cull)
# schedule periodic cull
pc = PeriodicCallback(cull, 1e3 * options.cull_every)
pc.start()
try:
loop.start()
except KeyboardInterrupt:
pass

View File

@@ -1,11 +0,0 @@
import sys
# run cull-idle as a service
c.JupyterHub.services = [
{
'name': 'cull-idle',
'admin': True,
'command': [sys.executable, 'cull_idle_servers.py', '--timeout=3600'],
}
]

View File

@@ -5,13 +5,11 @@ so all URLs and requests necessary for OAuth with JupyterHub should be in one pl
"""
import json
import os
import sys
from urllib.parse import urlencode
from urllib.parse import urlparse
from tornado import log
from tornado import web
from tornado.auth import OAuth2Mixin
from tornado.httpclient import AsyncHTTPClient
from tornado.httpclient import HTTPRequest
from tornado.httputil import url_concat

View File

@@ -4,7 +4,6 @@ import json
import os
from tornado import escape
from tornado import gen
from tornado import ioloop
from tornado import web

View File

@@ -1,6 +1,3 @@
import os
import sys
c.JupyterHub.services = [
{
'name': 'whoami',

View File

@@ -1,4 +1,3 @@
import os
import sys
c.JupyterHub.services = [

View File

@@ -6,7 +6,6 @@ showing the user their own info.
"""
import json
import os
from getpass import getuser
from urllib.parse import urlparse
from tornado.httpserver import HTTPServer
@@ -25,6 +24,7 @@ class WhoAmIHandler(HubOAuthenticated, RequestHandler):
# `getuser()` here would mean only the user who started the service
# can access the service:
# from getpass import getuser
# hub_users = {getuser()}
@authenticated

View File

@@ -4,7 +4,6 @@ This serves `/services/whoami/`, authenticated with the Hub, showing the user th
"""
import json
import os
from getpass import getuser
from urllib.parse import urlparse
from tornado.httpserver import HTTPServer
@@ -21,6 +20,7 @@ class WhoAmIHandler(HubAuthenticated, RequestHandler):
# `getuser()` here would mean only the user who started the service
# can access the service:
# from getpass import getuser
# hub_users = {getuser()}
@authenticated

View File

@@ -1,4 +1,7 @@
#!/bin/bash
set -exuo pipefail
# build jupyterhub-onbuild image
docker build --build-arg BASE_IMAGE=$DOCKER_REPO:$DOCKER_TAG -t ${DOCKER_REPO}-onbuild:$DOCKER_TAG onbuild
# build jupyterhub-demo image
docker build --build-arg BASE_IMAGE=${DOCKER_REPO}-onbuild:$DOCKER_TAG -t ${DOCKER_REPO}-demo:$DOCKER_TAG demo-image

View File

@@ -2,8 +2,11 @@
set -exuo pipefail
export ONBUILD=${DOCKER_REPO}-onbuild
export DEMO=${DOCKER_REPO}-demo
export REPOS="${DOCKER_REPO} ${ONBUILD} ${DEMO}"
# push ONBUILD image
docker push $ONBUILD:$DOCKER_TAG
docker push $DEMO:$DOCKER_TAG
function get_hub_version() {
rm -f hub_version
@@ -20,25 +23,20 @@ function get_hub_version() {
fi
}
get_hub_version
for repo in ${REPOS}; do
# when building master, push 0.9.0.dev as well
docker tag $DOCKER_REPO:$DOCKER_TAG $DOCKER_REPO:$hub_xyz
docker push $DOCKER_REPO:$hub_xyz
docker tag $ONBUILD:$DOCKER_TAG $ONBUILD:$hub_xyz
docker push $ONBUILD:$hub_xyz
docker tag $repo:$DOCKER_TAG $repo:$hub_xyz
docker push $repo:$hub_xyz
# when building 0.9.x, push 0.9 as well
docker tag $DOCKER_REPO:$DOCKER_TAG $DOCKER_REPO:$hub_xy
docker push $DOCKER_REPO:$hub_xy
docker tag $ONBUILD:$DOCKER_TAG $ONBUILD:$hub_xy
docker push $ONBUILD:$hub_xyz
docker tag $repo:$DOCKER_TAG $repo:$hub_xy
docker push $repo:$hub_xy
# if building a stable release, tag latest as well
if [[ "$latest" == "1" ]]; then
docker tag $DOCKER_REPO:$DOCKER_TAG $DOCKER_REPO:latest
docker push $DOCKER_REPO:latest
docker tag $ONBUILD:$DOCKER_TAG $ONBUILD:latest
docker push $ONBUILD:latest
docker tag $repo:$DOCKER_TAG $repo:latest
docker push $repo:latest
fi
done

View File

@@ -4,9 +4,9 @@
version_info = (
1,
1,
0,
# "", # release (b1, rc1, or "" for final or dev)
2,
2,
"", # release (b1, rc1, or "" for final or dev)
# "dev", # dev or nothing for beta/rc/stable releases
)
@@ -18,6 +18,15 @@ version_info = (
__version__ = ".".join(map(str, version_info[:3])) + ".".join(version_info[3:])
# Singleton flag to only log the major/minor mismatch warning once per mismatch combo.
_version_mismatch_warning_logged = {}
def reset_globals():
"""Used to reset globals between test cases."""
global _version_mismatch_warning_logged
_version_mismatch_warning_logged = {}
def _check_version(hub_version, singleuser_version, log):
"""Compare Hub and single-user server versions"""
@@ -42,14 +51,22 @@ def _check_version(hub_version, singleuser_version, log):
hub_major_minor = V(hub_version).version[:2]
singleuser_major_minor = V(singleuser_version).version[:2]
extra = ""
do_log = True
if singleuser_major_minor == hub_major_minor:
# patch-level mismatch or lower, log difference at debug-level
# because this should be fine
log_method = log.debug
else:
# log warning-level for more significant mismatch, such as 0.8 vs 0.9, etc.
key = '%s-%s' % (hub_version, singleuser_version)
global _version_mismatch_warning_logged
if _version_mismatch_warning_logged.get(key):
do_log = False # We already logged this warning so don't log it again.
else:
log_method = log.warning
extra = " This could cause failure to authenticate and result in redirect loops!"
_version_mismatch_warning_logged[key] = True
if do_log:
log_method(
"jupyterhub version %s != jupyterhub-singleuser version %s." + extra,
hub_version,

View File

@@ -201,7 +201,7 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
def needs_oauth_confirm(self, user, oauth_client):
"""Return whether the given oauth client needs to prompt for access for the given user
Checks whitelist for oauth clients
Checks list for oauth clients that don't need confirmation
(i.e. the user's own server)
@@ -214,9 +214,9 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
if (
# it's the user's own server
oauth_client.identifier in own_oauth_client_ids
# or it's in the global whitelist
# or it's in the global no-confirm list
or oauth_client.identifier
in self.settings.get('oauth_no_confirm_whitelist', set())
in self.settings.get('oauth_no_confirm_list', set())
):
return False
# default: require confirmation
@@ -229,7 +229,7 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
Render oauth confirmation page:
"Server at ... would like permission to ...".
Users accessing their own server or a service whitelist
Users accessing their own server or a blessed service
will skip confirmation.
"""
@@ -275,9 +275,26 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
uri, http_method, body, headers = self.extract_oauth_params()
referer = self.request.headers.get('Referer', 'no referer')
full_url = self.request.full_url()
if referer != full_url:
# trim protocol, which cannot be trusted with multiple layers of proxies anyway
# Referer is set by browser, but full_url can be modified by proxy layers to appear as http
# when it is actually https
referer_proto, _, stripped_referer = referer.partition("://")
referer_proto = referer_proto.lower()
req_proto, _, stripped_full_url = full_url.partition("://")
req_proto = req_proto.lower()
if referer_proto != req_proto:
self.log.warning("Protocol mismatch: %s != %s", referer, full_url)
if req_proto == "https":
# insecure origin to secure target is not allowed
raise web.HTTPError(
403, "Not allowing authorization form submitted from insecure page"
)
if stripped_referer != stripped_full_url:
# OAuth post must be made to the URL it came from
self.log.error("OAuth POST from %s != %s", referer, full_url)
self.log.error("Original OAuth POST from %s != %s", referer, full_url)
self.log.error(
"Stripped OAuth POST from %s != %s", stripped_referer, stripped_full_url
)
raise web.HTTPError(
403, "Authorization form must be sent from authorization page"
)

View File

@@ -3,7 +3,6 @@
# Distributed under the terms of the Modified BSD License.
import json
from tornado import gen
from tornado import web
from .. import orm

View File

@@ -2,12 +2,9 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import json
from urllib.parse import urlparse
from tornado import gen
from tornado import web
from .. import orm
from ..utils import admin_only
from .base import APIHandler

View File

@@ -23,6 +23,7 @@ def service_model(service):
'command': service.command,
'pid': service.proc.pid if service.proc else 0,
'info': service.info,
'display': service.display,
}

View File

@@ -625,14 +625,14 @@ def _parse_timestamp(timestamp):
- raise HTTPError(400) on parse error
- handle and strip tz info for internal consistency
(we use naïve utc timestamps everywhere)
(we use naive utc timestamps everywhere)
"""
try:
dt = parse_date(timestamp)
except Exception:
raise web.HTTPError(400, "Not a valid timestamp: %r", timestamp)
if dt.tzinfo:
# strip timezone info to naïve UTC datetime
# strip timezone info to naive UTC datetime
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
now = datetime.utcnow()

View File

@@ -55,6 +55,7 @@ from traitlets import (
Instance,
Bytes,
Float,
Union,
observe,
default,
validate,
@@ -76,6 +77,7 @@ from .user import UserDict
from .oauth.provider import make_provider
from ._data import DATA_FILES_PATH
from .log import CoroutineLogFormatter, log_request
from .pagination import Pagination
from .proxy import Proxy, ConfigurableHTTPProxy
from .traitlets import URLPrefix, Command, EntryPointType, Callable
from .utils import (
@@ -278,7 +280,7 @@ class JupyterHub(Application):
@default('classes')
def _load_classes(self):
classes = [Spawner, Authenticator, CryptKeeper]
classes = [Spawner, Authenticator, CryptKeeper, Pagination]
for name, trait in self.traits(config=True).items():
# load entry point groups into configurable class list
# so that they show up in config files, etc.
@@ -316,7 +318,7 @@ class JupyterHub(Application):
@validate("config_file")
def _validate_config_file(self, proposal):
if not os.path.isfile(proposal.value):
if not self.generate_config and not os.path.isfile(proposal.value):
print(
"ERROR: Failed to find specified config file: {}".format(
proposal.value
@@ -561,10 +563,23 @@ class JupyterHub(Application):
def _url_part_changed(self, change):
"""propagate deprecated ip/port/base_url config to the bind_url"""
urlinfo = urlparse(self.bind_url)
urlinfo = urlinfo._replace(netloc='%s:%i' % (self.ip, self.port))
if ':' in self.ip:
fmt = '[%s]:%i'
else:
fmt = '%s:%i'
urlinfo = urlinfo._replace(netloc=fmt % (self.ip, self.port))
urlinfo = urlinfo._replace(path=self.base_url)
bind_url = urlunparse(urlinfo)
# Warn if both bind_url and ip/port/base_url are set
if bind_url != self.bind_url:
if self.bind_url != self._bind_url_default():
self.log.warning(
"Both bind_url and ip/port/base_url have been configured. "
"JupyterHub.ip, JupyterHub.port, JupyterHub.base_url are"
" deprecated in JupyterHub 0.9,"
" please use JupyterHub.bind_url instead."
)
self.bind_url = bind_url
bind_url = Unicode(
@@ -576,6 +591,22 @@ class JupyterHub(Application):
""",
).tag(config=True)
@validate('bind_url')
def _validate_bind_url(self, proposal):
"""ensure protocol field of bind_url matches ssl"""
v = proposal['value']
proto, sep, rest = v.partition('://')
if self.ssl_cert and proto != 'https':
return 'https' + sep + rest
elif proto != 'http' and not self.ssl_cert:
return 'http' + sep + rest
return v
@default('bind_url')
def _bind_url_default(self):
proto = 'https' if self.ssl_cert else 'http'
return proto + '://:8000'
subdomain_host = Unicode(
'',
help="""Run single-user servers on subdomains of this host.
@@ -711,10 +742,10 @@ class JupyterHub(Application):
help="""The ip or hostname for proxies and spawners to use
for connecting to the Hub.
Use when the bind address (`hub_ip`) is 0.0.0.0 or otherwise different
Use when the bind address (`hub_ip`) is 0.0.0.0, :: or otherwise different
from the connect address.
Default: when `hub_ip` is 0.0.0.0, use `socket.gethostname()`, otherwise use `hub_ip`.
Default: when `hub_ip` is 0.0.0.0 or ::, use `socket.gethostname()`, otherwise use `hub_ip`.
Note: Some spawners or proxy implementations might not support hostnames. Check your
spawner or proxy documentation to see if they have extra requirements.
@@ -917,6 +948,25 @@ class JupyterHub(Application):
def _authenticator_default(self):
return self.authenticator_class(parent=self, db=self.db)
implicit_spawn_seconds = Float(
0,
help="""Trigger implicit spawns after this many seconds.
When a user visits a URL for a server that's not running,
they are shown a page indicating that the requested server
is not running with a button to spawn the server.
Setting this to a positive value will redirect the user
after this many seconds, effectively clicking this button
automatically for the users,
automatically beginning the spawn process.
Warning: this can result in errors and surprising behavior
when sharing access URLs to actual servers,
since the wrong server is likely to be started.
""",
).tag(config=True)
allow_named_servers = Bool(
False, help="Allow named single-user servers per user"
).tag(config=True)
@@ -1266,12 +1316,25 @@ class JupyterHub(Application):
"""
).tag(config=True)
default_url = Unicode(
default_url = Union(
[Unicode(), Callable()],
help="""
The default URL for users when they arrive (e.g. when user directs to "/")
By default, redirects users to their own server.
"""
Can be a Unicode string (e.g. '/hub/home') or a callable based on the handler object:
::
def default_url_fn(handler):
user = handler.current_user
if user and user.admin:
return '/hub/admin'
return '/hub/home'
c.JupyterHub.default_url = default_url_fn
""",
).tag(config=True)
user_redirect_hook = Callable(
@@ -1641,22 +1704,22 @@ class JupyterHub(Application):
# the admin_users config variable will never be used after this point.
# only the database values will be referenced.
whitelist = [
allowed_users = [
self.authenticator.normalize_username(name)
for name in self.authenticator.whitelist
for name in self.authenticator.allowed_users
]
self.authenticator.whitelist = set(whitelist) # force normalization
for username in whitelist:
self.authenticator.allowed_users = set(allowed_users) # force normalization
for username in allowed_users:
if not self.authenticator.validate_username(username):
raise ValueError("username %r is not valid" % username)
if not whitelist:
if not allowed_users:
self.log.info(
"Not using whitelist. Any authenticated user will be allowed."
"Not using allowed_users. Any authenticated user will be allowed."
)
# add whitelisted users to the db
for name in whitelist:
# add allowed users to the db
for name in allowed_users:
user = orm.User.find(db, name)
if user is None:
user = orm.User(name=name)
@@ -1666,13 +1729,16 @@ class JupyterHub(Application):
db.commit()
# Notify authenticator of all users.
# This ensures Auth whitelist is up-to-date with the database.
# This lets whitelist be used to set up initial list,
# but changes to the whitelist can occur in the database,
# This ensures Authenticator.allowed_users is up-to-date with the database.
# This lets .allowed_users be used to set up initial list,
# but changes to the allowed_users set can occur in the database,
# and persist across sessions.
total_users = 0
for user in db.query(orm.User):
try:
await maybe_future(self.authenticator.add_user(user))
f = self.authenticator.add_user(user)
if f:
await maybe_future(f)
except Exception:
self.log.exception("Error adding user %s already in db", user.name)
if self.authenticator.delete_invalid_users:
@@ -1694,6 +1760,7 @@ class JupyterHub(Application):
)
)
else:
total_users += 1
# handle database upgrades where user.created is undefined.
# we don't want to allow user.created to be undefined,
# so initialize it to last_activity (if defined) or now.
@@ -1701,9 +1768,11 @@ class JupyterHub(Application):
user.created = user.last_activity or datetime.utcnow()
db.commit()
# The whitelist set and the users in the db are now the same.
# The allowed_users set and the users in the db are now the same.
# From this point on, any user changes should be done simultaneously
# to the whitelist set and user db, unless the whitelist is empty (all users allowed).
# to the allowed_users set and user db, unless the allowed set is empty (all users allowed).
TOTAL_USERS.set(total_users)
async def init_groups(self):
"""Load predefined groups into the database"""
@@ -1716,11 +1785,11 @@ class JupyterHub(Application):
for username in usernames:
username = self.authenticator.normalize_username(username)
if not (
await maybe_future(
self.authenticator.check_whitelist(username, None)
)
await maybe_future(self.authenticator.check_allowed(username, None))
):
raise ValueError("Username %r is not in whitelist" % username)
raise ValueError(
"Username %r is not in Authenticator.allowed_users" % username
)
user = orm.User.find(db, name=username)
if user is None:
if not self.authenticator.validate_username(username):
@@ -1744,11 +1813,14 @@ class JupyterHub(Application):
if kind == 'user':
name = self.authenticator.normalize_username(name)
if not (
await maybe_future(self.authenticator.check_whitelist(name, None))
await maybe_future(self.authenticator.check_allowed(name, None))
):
raise ValueError("Token name %r is not in whitelist" % name)
raise ValueError(
"Token user name %r is not in Authenticator.allowed_users"
% name
)
if not self.authenticator.validate_username(name):
raise ValueError("Token name %r is not valid" % name)
raise ValueError("Token user name %r is not valid" % name)
if kind == 'service':
if not any(service["name"] == name for service in self.services):
self.log.warning(
@@ -1787,17 +1859,27 @@ class JupyterHub(Application):
# purge expired tokens hourly
purge_expired_tokens_interval = 3600
def purge_expired_tokens(self):
"""purge all expiring token objects from the database
run periodically
"""
# this should be all the subclasses of Expiring
for cls in (orm.APIToken, orm.OAuthAccessToken, orm.OAuthCode):
self.log.debug("Purging expired {name}s".format(name=cls.__name__))
cls.purge_expired(self.db)
async def init_api_tokens(self):
"""Load predefined API tokens (for services) into database"""
await self._add_tokens(self.service_tokens, kind='service')
await self._add_tokens(self.api_tokens, kind='user')
purge_expired_tokens = partial(orm.APIToken.purge_expired, self.db)
purge_expired_tokens()
self.purge_expired_tokens()
# purge expired tokens hourly
# we don't need to be prompt about this
# because expired tokens cannot be used anyway
pc = PeriodicCallback(
purge_expired_tokens, 1e3 * self.purge_expired_tokens_interval
self.purge_expired_tokens, 1e3 * self.purge_expired_tokens_interval
)
pc.start()
@@ -2005,22 +2087,31 @@ class JupyterHub(Application):
spawner._check_pending = False
# parallelize checks for running Spawners
# run query on extant Server objects
# so this is O(running servers) not O(total users)
# Server objects can be associated with either a Spawner or a Service,
# we are only interested in the ones associated with a Spawner
check_futures = []
for orm_user in db.query(orm.User):
user = self.users[orm_user]
self.log.debug("Loading state for %s from db", user.name)
for name, orm_spawner in user.orm_spawners.items():
if orm_spawner.server is not None:
# spawner should be running
for orm_server in db.query(orm.Server):
orm_spawner = orm_server.spawner
if not orm_spawner:
# check for orphaned Server rows
# this shouldn't happen if we've got our sqlachemy right
if not orm_server.service:
self.log.warning("deleting orphaned server %s", orm_server)
self.db.delete(orm_server)
self.db.commit()
continue
# instantiate Spawner wrapper and check if it's still alive
spawner = user.spawners[name]
# spawner should be running
user = self.users[orm_spawner.user]
spawner = user.spawners[orm_spawner.name]
self.log.debug("Loading state for %s from db", spawner._log_name)
# signal that check is pending to avoid race conditions
spawner._check_pending = True
f = asyncio.ensure_future(check_spawner(user, name, spawner))
f = asyncio.ensure_future(check_spawner(user, spawner.name, spawner))
check_futures.append(f)
TOTAL_USERS.set(len(self.users))
# it's important that we get here before the first await
# so that we know all spawners are instantiated and in the check-pending state
@@ -2120,14 +2211,14 @@ class JupyterHub(Application):
else:
version_hash = datetime.now().strftime("%Y%m%d%H%M%S")
oauth_no_confirm_whitelist = set()
oauth_no_confirm_list = set()
for service in self._service_map.values():
if service.oauth_no_confirm:
self.log.warning(
"Allowing service %s to complete OAuth without confirmation on an authorization web page",
service.name,
)
oauth_no_confirm_whitelist.add(service.oauth_client_id)
oauth_no_confirm_list.add(service.oauth_client_id)
settings = dict(
log_function=log_request,
@@ -2158,11 +2249,12 @@ class JupyterHub(Application):
subdomain_host=self.subdomain_host,
domain=self.domain,
statsd=self.statsd,
implicit_spawn_seconds=self.implicit_spawn_seconds,
allow_named_servers=self.allow_named_servers,
default_server_name=self._default_server_name,
named_server_limit_per_user=self.named_server_limit_per_user,
oauth_provider=self.oauth_provider,
oauth_no_confirm_whitelist=oauth_no_confirm_whitelist,
oauth_no_confirm_list=oauth_no_confirm_list,
concurrent_spawn_limit=self.concurrent_spawn_limit,
spawn_throttle_retry_range=self.spawn_throttle_retry_range,
active_server_limit=self.active_server_limit,
@@ -2296,7 +2388,6 @@ class JupyterHub(Application):
if init_spawners_timeout < 0:
# negative timeout means forever (previous, most stable behavior)
init_spawners_timeout = 86400
print(init_spawners_timeout)
init_start_time = time.perf_counter()
init_spawners_future = asyncio.ensure_future(self.init_spawners())
@@ -2452,7 +2543,7 @@ class JupyterHub(Application):
continue
dt = parse_date(route_data['last_activity'])
if dt.tzinfo:
# strip timezone info to naïve UTC datetime
# strip timezone info to naive UTC datetime
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
if user.last_activity:
@@ -2665,6 +2756,40 @@ class JupyterHub(Application):
self.log.critical("Received signalnum %s, , initiating shutdown...", signum)
raise SystemExit(128 + signum)
def _init_asyncio_patch(self):
"""Set default asyncio policy to be compatible with Tornado.
Tornado 6 (at least) is not compatible with the default
asyncio implementation on Windows.
Pick the older SelectorEventLoopPolicy on Windows
if the known-incompatible default policy is in use.
Do this as early as possible to make it a low priority and overrideable.
ref: https://github.com/tornadoweb/tornado/issues/2608
FIXME: If/when tornado supports the defaults in asyncio,
remove and bump tornado requirement for py38.
"""
if sys.platform.startswith("win") and sys.version_info >= (3, 8):
try:
from asyncio import (
WindowsProactorEventLoopPolicy,
WindowsSelectorEventLoopPolicy,
)
except ImportError:
pass
# not affected
else:
if (
type(asyncio.get_event_loop_policy())
is WindowsProactorEventLoopPolicy
):
# WindowsProactorEventLoopPolicy is not compatible with Tornado 6.
# Fallback to the pre-3.8 default of WindowsSelectorEventLoopPolicy.
asyncio.set_event_loop_policy(WindowsSelectorEventLoopPolicy())
_atexit_ran = False
def atexit(self):
@@ -2672,6 +2797,7 @@ class JupyterHub(Application):
if self._atexit_ran:
return
self._atexit_ran = True
self._init_asyncio_patch()
# run the cleanup step (in a new loop, because the interrupted one is unclean)
asyncio.set_event_loop(asyncio.new_event_loop())
IOLoop.clear_current()
@@ -2721,6 +2847,7 @@ class JupyterHub(Application):
@classmethod
def launch_instance(cls, argv=None):
self = cls.instance()
self._init_asyncio_patch()
loop = IOLoop.current()
task = asyncio.ensure_future(self.launch_instance_async(argv))
try:

View File

@@ -7,6 +7,7 @@ import re
import sys
import warnings
from concurrent.futures import ThreadPoolExecutor
from functools import partial
from shutil import which
from subprocess import PIPE
from subprocess import Popen
@@ -100,41 +101,74 @@ class Authenticator(LoggingConfigurable):
"""
).tag(config=True)
whitelist = Set(
whitelist = Set(help="Deprecated, use `Authenticator.allowed_users`", config=True,)
allowed_users = Set(
help="""
Whitelist of usernames that are allowed to log in.
Set of usernames that are allowed to log in.
Use this with supported authenticators to restrict which users can log in. This is an
additional whitelist that further restricts users, beyond whatever restrictions the
additional list that further restricts users, beyond whatever restrictions the
authenticator has in place.
If empty, does not perform any additional restriction.
.. versionchanged:: 1.2
`Authenticator.whitelist` renamed to `allowed_users`
"""
).tag(config=True)
blacklist = Set(
blocked_users = Set(
help="""
Blacklist of usernames that are not allowed to log in.
Set of usernames that are not allowed to log in.
Use this with supported authenticators to restrict which users can not log in. This is an
additional blacklist that further restricts users, beyond whatever restrictions the
additional block list that further restricts users, beyond whatever restrictions the
authenticator has in place.
If empty, does not perform any additional restriction.
.. versionadded: 0.9
.. versionchanged:: 1.2
`Authenticator.blacklist` renamed to `blocked_users`
"""
).tag(config=True)
@observe('whitelist')
def _check_whitelist(self, change):
_deprecated_aliases = {
"whitelist": ("allowed_users", "1.2"),
"blacklist": ("blocked_users", "1.2"),
}
@observe(*list(_deprecated_aliases))
def _deprecated_trait(self, change):
"""observer for deprecated traits"""
old_attr = change.name
new_attr, version = self._deprecated_aliases.get(old_attr)
new_value = getattr(self, new_attr)
if new_value != change.new:
# only warn if different
# protects backward-compatible config from warnings
# if they set the same value under both names
self.log.warning(
"{cls}.{old} is deprecated in JupyterHub {version}, use {cls}.{new} instead".format(
cls=self.__class__.__name__,
old=old_attr,
new=new_attr,
version=version,
)
)
setattr(self, new_attr, change.new)
@observe('allowed_users')
def _check_allowed_users(self, change):
short_names = [name for name in change['new'] if len(name) <= 1]
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?",
"Allowed set contains single-character names: %s; did you mean set([%r]) instead of %s?",
sorted_names[:8],
single,
string_set_typo,
@@ -206,6 +240,7 @@ class Authenticator(LoggingConfigurable):
delete_invalid_users = Bool(
False,
config=True,
help="""Delete any users from the database that do not pass validation
When JupyterHub starts, `.add_user` will be called
@@ -260,39 +295,74 @@ class Authenticator(LoggingConfigurable):
def __init__(self, **kwargs):
super().__init__(**kwargs)
for method_name in (
'check_whitelist',
'check_blacklist',
'check_group_whitelist',
self._init_deprecated_methods()
def _init_deprecated_methods(self):
# handles deprecated signature *and* name
# with correct subclass override priority!
for old_name, new_name in (
('check_whitelist', 'check_allowed'),
('check_blacklist', 'check_blocked_users'),
('check_group_whitelist', 'check_allowed_groups'),
):
original_method = getattr(self, method_name, None)
if original_method is None:
old_method = getattr(self, old_name, None)
if old_method is None:
# no such method (check_group_whitelist is optional)
continue
signature = inspect.signature(original_method)
if 'authentication' not in signature.parameters:
# allow old name to have higher priority
# if and only if it's defined in a later subclass
# than the new name
for cls in self.__class__.mro():
has_old_name = old_name in cls.__dict__
has_new_name = new_name in cls.__dict__
if has_new_name:
break
if has_old_name and not has_new_name:
warnings.warn(
"{0}.{1} should be renamed to {0}.{2} for JupyterHub >= 1.2".format(
cls.__name__, old_name, new_name
),
DeprecationWarning,
)
# use old name instead of new
# if old name is overridden in subclass
def _new_calls_old(old_name, *args, **kwargs):
return getattr(self, old_name)(*args, **kwargs)
setattr(self, new_name, partial(_new_calls_old, old_name))
break
# deprecate pre-1.0 method signatures
signature = inspect.signature(old_method)
if 'authentication' not in signature.parameters and not any(
param.kind == inspect.Parameter.VAR_KEYWORD
for param in signature.parameters.values()
):
# adapt to pre-1.0 signature for compatibility
warnings.warn(
"""
{0}.{1} does not support the authentication argument,
added in JupyterHub 1.0.
added in JupyterHub 1.0. and is renamed to {2} in JupyterHub 1.2.
It should have the signature:
def {1}(self, username, authentication=None):
def {2}(self, username, authentication=None):
...
Adapting for compatibility.
""".format(
self.__class__.__name__, method_name
self.__class__.__name__, old_name, new_name
),
DeprecationWarning,
)
def wrapped_method(username, authentication=None, **kwargs):
def wrapped_method(
original_method, username, authentication=None, **kwargs
):
return original_method(username, **kwargs)
setattr(self, method_name, wrapped_method)
setattr(self, old_name, partial(wrapped_method, old_method))
async def run_post_auth_hook(self, handler, authentication):
"""
@@ -326,39 +396,45 @@ class Authenticator(LoggingConfigurable):
username = self.username_map.get(username, username)
return username
def check_whitelist(self, username, authentication=None):
"""Check if a username is allowed to authenticate based on whitelist configuration
def check_allowed(self, username, authentication=None):
"""Check if a username is allowed to authenticate based on configuration
Return True if username is allowed, False otherwise.
No whitelist means any username is allowed.
No allowed_users set means any username is allowed.
Names are normalized *before* being checked against the whitelist.
Names are normalized *before* being checked against the allowed set.
.. versionchanged:: 1.0
Signature updated to accept authentication data and any future changes
"""
if not self.whitelist:
# No whitelist means any name is allowed
return True
return username in self.whitelist
def check_blacklist(self, username, authentication=None):
"""Check if a username is blocked to authenticate based on blacklist configuration
.. versionchanged:: 1.2
Renamed check_whitelist to check_allowed
"""
if not self.allowed_users:
# No allowed set means any name is allowed
return True
return username in self.allowed_users
def check_blocked_users(self, username, authentication=None):
"""Check if a username is blocked to authenticate based on Authenticator.blocked configuration
Return True if username is allowed, False otherwise.
No blacklist means any username is allowed.
No block list means any username is allowed.
Names are normalized *before* being checked against the blacklist.
Names are normalized *before* being checked against the block list.
.. versionadded: 0.9
.. versionchanged:: 1.0
Signature updated to accept authentication data as second argument
.. versionchanged:: 1.2
Renamed check_blacklist to check_blocked_users
"""
if not self.blacklist:
# No blacklist means any name is allowed
if not self.blocked_users:
# No block list means any name is allowed
return True
return username not in self.blacklist
return username not in self.blocked_users
async def get_authenticated_user(self, handler, data):
"""Authenticate the user who is attempting to log in
@@ -367,7 +443,7 @@ class Authenticator(LoggingConfigurable):
This calls `authenticate`, which should be overridden in subclasses,
normalizes the username if any normalization should be done,
and then validates the name in the whitelist.
and then validates the name in the allowed set.
This is the outer API for authenticating a user.
Subclasses should not override this method.
@@ -375,7 +451,7 @@ class Authenticator(LoggingConfigurable):
The various stages can be overridden separately:
- `authenticate` turns formdata into a username
- `normalize_username` normalizes the username
- `check_whitelist` checks against the user whitelist
- `check_allowed` checks against the allowed usernames
.. versionchanged:: 0.8
return dict instead of username
@@ -389,7 +465,7 @@ class Authenticator(LoggingConfigurable):
else:
authenticated = {'name': authenticated}
authenticated.setdefault('auth_state', None)
# Leave the default as None, but reevaluate later post-whitelist
# Leave the default as None, but reevaluate later post-allowed-check
authenticated.setdefault('admin', None)
# normalize the username
@@ -400,20 +476,18 @@ class Authenticator(LoggingConfigurable):
self.log.warning("Disallowing invalid username %r.", username)
return
blacklist_pass = await maybe_future(
self.check_blacklist(username, authenticated)
)
whitelist_pass = await maybe_future(
self.check_whitelist(username, authenticated)
blocked_pass = await maybe_future(
self.check_blocked_users(username, authenticated)
)
allowed_pass = await maybe_future(self.check_allowed(username, authenticated))
if blacklist_pass:
if blocked_pass:
pass
else:
self.log.warning("User %r in blacklist. Stop authentication", username)
self.log.warning("User %r blocked. Stop authentication", username)
return
if whitelist_pass:
if allowed_pass:
if authenticated['admin'] is None:
authenticated['admin'] = await maybe_future(
self.is_admin(handler, authenticated)
@@ -423,7 +497,7 @@ class Authenticator(LoggingConfigurable):
return authenticated
else:
self.log.warning("User %r not in whitelist.", username)
self.log.warning("User %r not allowed.", username)
return
async def refresh_user(self, user, handler=None):
@@ -479,7 +553,7 @@ class Authenticator(LoggingConfigurable):
It must return the username on successful authentication,
and return None on failed authentication.
Checking the whitelist is handled separately by the caller.
Checking allowed_users/blocked_users is handled separately by the caller.
.. versionchanged:: 0.8
Allow `authenticate` to return a dict containing auth_state.
@@ -520,10 +594,10 @@ class Authenticator(LoggingConfigurable):
This method may be a coroutine.
By default, this just adds the user to the whitelist.
By default, this just adds the user to the allowed_users set.
Subclasses may do more extensive things, such as adding actual unix users,
but they should call super to ensure the whitelist is updated.
but they should call super to ensure the allowed_users set is updated.
Note that this should be idempotent, since it is called whenever the hub restarts
for all users.
@@ -533,19 +607,19 @@ class Authenticator(LoggingConfigurable):
"""
if not self.validate_username(user.name):
raise ValueError("Invalid username: %s" % user.name)
if self.whitelist:
self.whitelist.add(user.name)
if self.allowed_users:
self.allowed_users.add(user.name)
def delete_user(self, user):
"""Hook called when a user is deleted
Removes the user from the whitelist.
Subclasses should call super to ensure the whitelist is updated.
Removes the user from the allowed_users set.
Subclasses should call super to ensure the allowed_users set is updated.
Args:
user (User): The User wrapper object
"""
self.whitelist.discard(user.name)
self.allowed_users.discard(user.name)
auto_login = Bool(
False,
@@ -610,6 +684,41 @@ class Authenticator(LoggingConfigurable):
return [('/login', LoginHandler)]
def _deprecated_method(old_name, new_name, version):
"""Create a deprecated method wrapper for a deprecated method name"""
def deprecated(self, *args, **kwargs):
warnings.warn(
(
"{cls}.{old_name} is deprecated in JupyterHub {version}."
" Please use {cls}.{new_name} instead."
).format(
cls=self.__class__.__name__,
old_name=old_name,
new_name=new_name,
version=version,
),
DeprecationWarning,
stacklevel=2,
)
old_method = getattr(self, new_name)
return old_method(*args, **kwargs)
return deprecated
import types
# deprecate white/blacklist method names
for _old_name, _new_name, _version in [
("check_whitelist", "check_allowed", "1.2"),
("check_blacklist", "check_blocked_users", "1.2"),
]:
setattr(
Authenticator, _old_name, _deprecated_method(_old_name, _new_name, _version),
)
class LocalAuthenticator(Authenticator):
"""Base class for Authenticators that work with local Linux/UNIX users
@@ -669,37 +778,37 @@ class LocalAuthenticator(Authenticator):
"""
).tag(config=True)
group_whitelist = Set(
help="""
Whitelist all users from this UNIX group.
group_whitelist = Set(help="""DEPRECATED: use allowed_groups""",).tag(config=True)
This makes the username whitelist ineffective.
allowed_groups = Set(
help="""
Allow login from all users in these UNIX groups.
If set, allowed username set is ignored.
"""
).tag(config=True)
@observe('group_whitelist')
def _group_whitelist_changed(self, change):
"""
Log a warning if both group_whitelist and user whitelist are set.
"""
if self.whitelist:
@observe('allowed_groups')
def _allowed_groups_changed(self, change):
"""Log a warning if mutually exclusive user and group allowed sets are specified."""
if self.allowed_users:
self.log.warning(
"Ignoring username whitelist because group whitelist supplied!"
"Ignoring Authenticator.allowed_users set because Authenticator.allowed_groups supplied!"
)
def check_whitelist(self, username, authentication=None):
if self.group_whitelist:
return self.check_group_whitelist(username, authentication)
def check_allowed(self, username, authentication=None):
if self.allowed_groups:
return self.check_allowed_groups(username, authentication)
else:
return super().check_whitelist(username, authentication)
return super().check_allowed(username, authentication)
def check_group_whitelist(self, username, authentication=None):
def check_allowed_groups(self, username, authentication=None):
"""
If group_whitelist is configured, check if authenticating user is part of group.
If allowed_groups is configured, check if authenticating user is part of group.
"""
if not self.group_whitelist:
if not self.allowed_groups:
return False
for grnam in self.group_whitelist:
for grnam in self.allowed_groups:
try:
group = self._getgrnam(grnam)
except KeyError:
@@ -843,7 +952,7 @@ class PAMAuthenticator(LocalAuthenticator):
Authoritative list of user groups that determine admin access.
Users not in these groups can still be granted admin status through admin_users.
White/blacklisting rules still apply.
allowed/blocked rules still apply.
"""
).tag(config=True)
@@ -986,6 +1095,16 @@ class PAMAuthenticator(LocalAuthenticator):
return super().normalize_username(username)
for _old_name, _new_name, _version in [
("check_group_whitelist", "check_group_allowed", "1.2"),
]:
setattr(
LocalAuthenticator,
_old_name,
_deprecated_method(_old_name, _new_name, _version),
)
class DummyAuthenticator(Authenticator):
"""Dummy Authenticator for testing

View File

@@ -6,7 +6,6 @@ from concurrent.futures import ThreadPoolExecutor
from traitlets import Any
from traitlets import default
from traitlets import Dict
from traitlets import Integer
from traitlets import List
from traitlets import observe

View File

@@ -2,7 +2,6 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import asyncio
import copy
import json
import math
import random
@@ -27,14 +26,12 @@ from tornado.httputil import url_concat
from tornado.ioloop import IOLoop
from tornado.log import app_log
from tornado.web import addslash
from tornado.web import MissingArgumentError
from tornado.web import RequestHandler
from .. import __version__
from .. import orm
from ..metrics import PROXY_ADD_DURATION_SECONDS
from ..metrics import PROXY_DELETE_DURATION_SECONDS
from ..metrics import ProxyAddStatus
from ..metrics import ProxyDeleteStatus
from ..metrics import RUNNING_SERVERS
from ..metrics import SERVER_POLL_DURATION_SECONDS
@@ -637,9 +634,22 @@ class BaseHandler(RequestHandler):
next_url,
)
# this is where we know if next_url is coming from ?next= param or we are using a default url
if next_url:
next_url_from_param = True
else:
next_url_from_param = False
if not next_url:
# custom default URL
next_url = default or self.default_url
# custom default URL, usually passed because user landed on that page but was not logged in
if default:
next_url = default
else:
# As set in jupyterhub_config.py
if callable(self.default_url):
next_url = self.default_url(self)
else:
next_url = self.default_url
if not next_url:
# default URL after login
@@ -654,8 +664,45 @@ class BaseHandler(RequestHandler):
next_url = url_path_join(self.hub.base_url, 'spawn')
else:
next_url = url_path_join(self.hub.base_url, 'home')
if not next_url_from_param:
# when a request made with ?next=... assume all the params have already been encoded
# otherwise, preserve params from the current request across the redirect
next_url = self.append_query_parameters(next_url, exclude=['next'])
return next_url
def append_query_parameters(self, url, exclude=None):
"""Append the current request's query parameters to the given URL.
Supports an extra optional parameter ``exclude`` that when provided must
contain a list of parameters to be ignored, i.e. these parameters will
not be added to the URL.
This is important to avoid infinite loops with the next parameter being
added over and over, for instance.
The default value for ``exclude`` is an array with "next". This is useful
as most use cases in JupyterHub (all?) won't want to include the next
parameter twice (the next parameter is added elsewhere to the query
parameters).
:param str url: a URL
:param list exclude: optional list of parameters to be ignored, defaults to
a list with "next" (to avoid redirect-loops)
:rtype (str)
"""
if exclude is None:
exclude = ['next']
if self.request.query:
query_string = [
param
for param in parse_qsl(self.request.query)
if param[0] not in exclude
]
if query_string:
url = url_concat(url, query_string)
return url
async def auth_to_user(self, authenticated, user=None):
"""Persist data from .authenticate() or .refresh_user() to the User database
@@ -676,9 +723,10 @@ class BaseHandler(RequestHandler):
raise ValueError("Username doesn't match! %s != %s" % (username, user.name))
if user is None:
new_user = username not in self.users
user = self.user_from_username(username)
user = self.find_user(username)
new_user = user is None
if new_user:
user = self.user_from_username(username)
await maybe_future(self.authenticator.add_user(user))
# Only set `admin` if the authenticator returned an explicit value.
if admin is not None and admin != user.admin:
@@ -877,7 +925,7 @@ class BaseHandler(RequestHandler):
self.log.error(
"Stopping %s to avoid inconsistent state", user_server_name
)
await user.stop()
await user.stop(server_name)
PROXY_ADD_DURATION_SECONDS.labels(status='failure').observe(
time.perf_counter() - proxy_add_start_time
)
@@ -910,6 +958,9 @@ class BaseHandler(RequestHandler):
self.settings['failure_count'] = 0
return
# spawn failed, increment count and abort if limit reached
SERVER_SPAWN_DURATION_SECONDS.labels(
status=ServerSpawnStatus.failure
).observe(time.perf_counter() - spawn_start_time)
self.settings.setdefault('failure_count', 0)
self.settings['failure_count'] += 1
failure_count = self.settings['failure_count']
@@ -942,6 +993,9 @@ class BaseHandler(RequestHandler):
# waiting_for_response indicates server process has started,
# but is yet to become responsive.
if spawner._spawn_pending and not spawner._waiting_for_response:
# If slow_spawn_timeout is intentionally disabled then we
# don't need to log a warning, just return.
if self.slow_spawn_timeout > 0:
# still in Spawner.start, which is taking a long time
# we shouldn't poll while spawn is incomplete.
self.log.warning(
@@ -1085,7 +1139,10 @@ class BaseHandler(RequestHandler):
except gen.TimeoutError:
# hit timeout, but stop is still pending
self.log.warning(
"User %s:%s server is slow to stop", user.name, server_name
"User %s:%s server is slow to stop (timeout=%s)",
user.name,
server_name,
self.slow_stop_timeout,
)
# return handle on the future for hooking up callbacks
@@ -1144,6 +1201,8 @@ class BaseHandler(RequestHandler):
for service in self.services.values():
if not service.url:
continue
if not service.display:
continue
accessible_services.append(service)
return accessible_services
@@ -1426,11 +1485,12 @@ class UserUrlHandler(BaseHandler):
# serve a page prompting for spawn and 503 error
# visiting /user/:name no longer triggers implicit spawn
# without explicit user action
self.set_status(503)
spawn_url = url_concat(
url_path_join(self.hub.base_url, "spawn", user.escaped_name, server_name),
{"next": self.request.uri},
)
self.set_status(503)
auth_state = await user.get_auth_state()
html = self.render_template(
"not_running.html",
@@ -1438,6 +1498,7 @@ class UserUrlHandler(BaseHandler):
server_name=server_name,
spawn_url=spawn_url,
auth_state=auth_state,
implicit_spawn_seconds=self.settings.get("implicit_spawn_seconds", 0),
)
self.finish(html)

View File

@@ -1,7 +1,6 @@
from prometheus_client import CONTENT_TYPE_LATEST
from prometheus_client import generate_latest
from prometheus_client import REGISTRY
from tornado import gen
from ..utils import metrics_authentication
from .base import BaseHandler

View File

@@ -2,22 +2,21 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import asyncio
import codecs
import copy
import time
from collections import defaultdict
from datetime import datetime
from http.client import responses
from jinja2 import TemplateNotFound
from tornado import gen
from tornado import web
from tornado.httputil import url_concat
from tornado.httputil import urlparse
from .. import __version__
from .. import orm
from ..metrics import SERVER_POLL_DURATION_SECONDS
from ..metrics import ServerPollStatus
from ..pagination import Pagination
from ..utils import admin_only
from ..utils import maybe_future
from ..utils import url_path_join
@@ -41,11 +40,15 @@ class RootHandler(BaseHandler):
def get(self):
user = self.current_user
if self.default_url:
# As set in jupyterhub_config.py
if callable(self.default_url):
url = self.default_url(self)
else:
url = self.default_url
elif user:
url = self.get_next_url(user)
else:
url = self.settings['login_url']
url = url_concat(self.settings["login_url"], dict(next=self.request.uri))
self.redirect(url)
@@ -151,17 +154,7 @@ class SpawnHandler(BaseHandler):
spawner = user.spawners[server_name]
# resolve `?next=...`, falling back on the spawn-pending url
# must not be /user/server for named servers,
# which may get handled by the default server if they aren't ready yet
pending_url = url_path_join(
self.hub.base_url, "spawn-pending", user.escaped_name, server_name
)
if self.get_argument('next', None):
# preserve `?next=...` through spawn-pending
pending_url = url_concat(pending_url, {'next': self.get_argument('next')})
pending_url = self._get_pending_url(user, server_name)
# spawner is active, redirect back to get progress, etc.
if spawner.ready:
@@ -183,35 +176,50 @@ class SpawnHandler(BaseHandler):
auth_state = await user.get_auth_state()
await spawner.run_auth_state_hook(auth_state)
# Try to start server directly when query arguments are passed.
error_message = ''
query_options = {}
for key, byte_list in self.request.query_arguments.items():
query_options[key] = [bs.decode('utf8') for bs in byte_list]
# 'next' is reserved argument for redirect after spawn
query_options.pop('next', None)
if len(query_options) > 0:
try:
self.log.debug(
"Triggering spawn with supplied query arguments for %s",
spawner._log_name,
)
options = await maybe_future(spawner.options_from_query(query_options))
pending_url = self._get_pending_url(user, server_name)
return await self._wrap_spawn_single_user(
user, server_name, spawner, pending_url, options
)
except Exception as e:
self.log.error(
"Failed to spawn single-user server with query arguments",
exc_info=True,
)
error_message = str(e)
# fallback to behavior without failing query arguments
spawner_options_form = await spawner.get_options_form()
if spawner_options_form:
self.log.debug("Serving options form for %s", spawner._log_name)
form = await self._render_form(
for_user=user, spawner_options_form=spawner_options_form
for_user=user,
spawner_options_form=spawner_options_form,
message=error_message,
)
self.finish(form)
else:
self.log.debug(
"Triggering spawn with default options for %s", spawner._log_name
)
# Explicit spawn request: clear _spawn_future
# which may have been saved to prevent implicit spawns
# after a failure.
if spawner._spawn_future and spawner._spawn_future.done():
spawner._spawn_future = None
# not running, no form. Trigger spawn and redirect back to /user/:name
f = asyncio.ensure_future(self.spawn_single_user(user, server_name))
done, pending = await asyncio.wait([f], timeout=1)
# If spawn_single_user throws an exception, raise a 500 error
# otherwise it may cause a redirect loop
if f.done() and f.exception():
exc = f.exception()
raise web.HTTPError(
500,
"Error in Authenticator.pre_spawn_start: %s %s"
% (type(exc).__name__, str(exc)),
return await self._wrap_spawn_single_user(
user, server_name, spawner, pending_url
)
self.redirect(pending_url)
@web.authenticated
async def post(self, for_user=None, server_name=''):
@@ -241,8 +249,14 @@ class SpawnHandler(BaseHandler):
for key, byte_list in self.request.files.items():
form_options["%s_file" % key] = byte_list
try:
self.log.debug(
"Triggering spawn with supplied form options for %s", spawner._log_name
)
options = await maybe_future(spawner.options_from_form(form_options))
await self.spawn_single_user(user, server_name=server_name, options=options)
pending_url = self._get_pending_url(user, server_name)
return await self._wrap_spawn_single_user(
user, server_name, spawner, pending_url, options
)
except Exception as e:
self.log.error(
"Failed to spawn single-user server with form", exc_info=True
@@ -263,6 +277,47 @@ class SpawnHandler(BaseHandler):
)
self.redirect(next_url)
def _get_pending_url(self, user, server_name):
# resolve `?next=...`, falling back on the spawn-pending url
# must not be /user/server for named servers,
# which may get handled by the default server if they aren't ready yet
pending_url = url_path_join(
self.hub.base_url, "spawn-pending", user.escaped_name, server_name
)
pending_url = self.append_query_parameters(pending_url, exclude=['next'])
if self.get_argument('next', None):
# preserve `?next=...` through spawn-pending
pending_url = url_concat(pending_url, {'next': self.get_argument('next')})
return pending_url
async def _wrap_spawn_single_user(
self, user, server_name, spawner, pending_url, options=None
):
# Explicit spawn request: clear _spawn_future
# which may have been saved to prevent implicit spawns
# after a failure.
if spawner._spawn_future and spawner._spawn_future.done():
spawner._spawn_future = None
# not running, no form. Trigger spawn and redirect back to /user/:name
f = asyncio.ensure_future(
self.spawn_single_user(user, server_name, options=options)
)
done, pending = await asyncio.wait([f], timeout=1)
# If spawn_single_user throws an exception, raise a 500 error
# otherwise it may cause a redirect loop
if f.done() and f.exception():
exc = f.exception()
raise web.HTTPError(
500,
"Error in Authenticator.pre_spawn_start: %s %s"
% (type(exc).__name__, str(exc)),
)
return self.redirect(pending_url)
class SpawnPendingHandler(BaseHandler):
"""Handle /hub/spawn-pending/:user/:server
@@ -402,12 +457,15 @@ class AdminHandler(BaseHandler):
@web.authenticated
@admin_only
async def get(self):
page, per_page, offset = Pagination(config=self.config).get_page_args(self)
available = {'name', 'admin', 'running', 'last_activity'}
default_sort = ['admin', 'name']
mapping = {'running': orm.Spawner.server_id}
for name in available:
if name not in mapping:
mapping[name] = getattr(orm.User, name)
table = orm.User if name != "last_activity" else orm.Spawner
mapping[name] = getattr(table, name)
default_order = {
'name': 'asc',
@@ -442,14 +500,28 @@ class AdminHandler(BaseHandler):
# get User.col.desc() order objects
ordered = [getattr(c, o)() for c, o in zip(cols, orders)]
users = self.db.query(orm.User).outerjoin(orm.Spawner).order_by(*ordered)
users = (
self.db.query(orm.User)
.outerjoin(orm.Spawner)
.order_by(*ordered)
.limit(per_page)
.offset(offset)
)
users = [self._user_from_orm(u) for u in users]
from itertools import chain
running = []
for u in users:
running.extend(s for s in u.spawners.values() if s.active)
total = self.db.query(orm.User.id).count()
pagination = Pagination(
url=self.request.uri,
total=total,
page=page,
per_page=per_page,
config=self.config,
)
auth_state = await self.current_user.get_auth_state()
html = self.render_template(
'admin.html',
@@ -462,6 +534,7 @@ class AdminHandler(BaseHandler):
allow_named_servers=self.allow_named_servers,
named_server_limit_per_user=self.named_server_limit_per_user,
server_version='{} {}'.format(__version__, self.version_hash),
pagination=pagination,
)
self.finish(html)
@@ -585,10 +658,14 @@ class ProxyErrorHandler(BaseHandler):
class HealthCheckHandler(BaseHandler):
"""Answer to health check"""
"""Serve health check probes as quickly as possible"""
def get(self, *args):
self.finish()
# There is nothing for us to do other than return a positive
# HTTP status code as quickly as possible for GET or HEAD requests
def get(self):
pass
head = get
default_handlers = [

View File

@@ -12,6 +12,7 @@ from tornado.log import LogFormatter
from tornado.web import HTTPError
from tornado.web import StaticFileHandler
from .handlers.pages import HealthCheckHandler
from .metrics import prometheus_log_method
@@ -98,8 +99,12 @@ def _scrub_headers(headers):
headers = dict(headers)
if 'Authorization' in headers:
auth = headers['Authorization']
if auth.startswith('token '):
headers['Authorization'] = 'token [secret]'
if ' ' in auth:
auth_type = auth.split(' ', 1)[0]
else:
# no space, hide the whole thing in case there was a mistake
auth_type = ''
headers['Authorization'] = '{} [secret]'.format(auth_type)
if 'Cookie' in headers:
c = SimpleCookie(headers['Cookie'])
redacted = []
@@ -123,7 +128,9 @@ def log_request(handler):
"""
status = handler.get_status()
request = handler.request
if status == 304 or (status < 300 and isinstance(handler, StaticFileHandler)):
if status == 304 or (
status < 300 and isinstance(handler, (StaticFileHandler, HealthCheckHandler))
):
# static-file success and 304 Found are debug-level
log_method = access_log.debug
elif status < 400:

View File

@@ -175,9 +175,9 @@ def prometheus_log_method(handler):
Tornado log handler for recording RED metrics.
We record the following metrics:
Rate the number of requests, per second, your services are serving.
Errors the number of failed requests per second.
Duration The amount of time each request takes expressed as a time interval.
Rate: the number of requests, per second, your services are serving.
Errors: the number of failed requests per second.
Duration: the amount of time each request takes expressed as a time interval.
We use a fully qualified name of the handler as a label,
rather than every url path to reduce cardinality.

View File

@@ -2,16 +2,11 @@
implements https://oauthlib.readthedocs.io/en/latest/oauth2/server.html
"""
from datetime import datetime
from urllib.parse import urlparse
from oauthlib import uri_validate
from oauthlib.oauth2 import RequestValidator
from oauthlib.oauth2 import WebApplicationServer
from oauthlib.oauth2.rfc6749.grant_types import authorization_code
from oauthlib.oauth2.rfc6749.grant_types import base
from sqlalchemy.orm import scoped_session
from tornado import web
from tornado.escape import url_escape
from tornado.log import app_log
@@ -250,7 +245,7 @@ class JupyterHubRequestValidator(RequestValidator):
client=orm_client,
code=code['code'],
# oauth has 5 minutes to complete
expires_at=int(datetime.utcnow().timestamp() + 300),
expires_at=int(orm.OAuthCode.now() + 300),
# TODO: persist oauth scopes
# scopes=request.scopes,
user=request.user.orm_user,
@@ -347,7 +342,7 @@ class JupyterHubRequestValidator(RequestValidator):
orm_access_token = orm.OAuthAccessToken(
client=client,
grant_type=orm.GrantType.authorization_code,
expires_at=datetime.utcnow().timestamp() + token['expires_in'],
expires_at=orm.OAuthAccessToken.now() + token['expires_in'],
refresh_token=token['refresh_token'],
# TODO: save scopes,
# scopes=scopes,
@@ -441,7 +436,7 @@ class JupyterHubRequestValidator(RequestValidator):
Method is used by:
- Authorization Code Grant
"""
orm_code = self.db.query(orm.OAuthCode).filter_by(code=code).first()
orm_code = orm.OAuthCode.find(self.db, code=code)
if orm_code is None:
app_log.debug("No such code: %s", code)
return False

View File

@@ -53,7 +53,7 @@ class Server(HasTraits):
Never used in APIs, only logging,
since it can be non-connectable value, such as '', meaning all interfaces.
"""
if self.ip in {'', '0.0.0.0'}:
if self.ip in {'', '0.0.0.0', '::'}:
return self.url.replace(self._connect_ip, self.ip or '*', 1)
return self.url
@@ -87,13 +87,13 @@ class Server(HasTraits):
"""The address to use when connecting to this server
When `ip` is set to a real ip address, the same value is used.
When `ip` refers to 'all interfaces' (e.g. '0.0.0.0'),
When `ip` refers to 'all interfaces' (e.g. '0.0.0.0' or '::'),
clients connect via hostname by default.
Setting `connect_ip` explicitly overrides any default behavior.
"""
if self.connect_ip:
return self.connect_ip
elif self.ip in {'', '0.0.0.0'}:
elif self.ip in {'', '0.0.0.0', '::'}:
# if listening on all interfaces, default to hostname for connect
return socket.gethostname()
else:
@@ -149,7 +149,12 @@ class Server(HasTraits):
if self.connect_url:
parsed = urlparse(self.connect_url)
return "{proto}://{host}".format(proto=parsed.scheme, host=parsed.netloc)
return "{proto}://{ip}:{port}".format(
if ':' in self._connect_ip:
fmt = "{proto}://[{ip}]:{port}"
else:
fmt = "{proto}://{ip}:{port}"
return fmt.format(
proto=self.proto, ip=self._connect_ip, port=self._connect_port
)

View File

@@ -26,6 +26,7 @@ from sqlalchemy import select
from sqlalchemy import Table
from sqlalchemy import Unicode
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import backref
from sqlalchemy.orm import interfaces
from sqlalchemy.orm import object_session
from sqlalchemy.orm import relationship
@@ -230,7 +231,12 @@ class Spawner(Base):
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
server_id = Column(Integer, ForeignKey('servers.id', ondelete='SET NULL'))
server = relationship(Server, cascade="all")
server = relationship(
Server,
backref=backref('spawner', uselist=False),
single_parent=True,
cascade="all, delete-orphan",
)
state = Column(JSONDict)
name = Column(Unicode(255))
@@ -282,7 +288,12 @@ class Service(Base):
# service-specific interface
_server_id = Column(Integer, ForeignKey('servers.id', ondelete='SET NULL'))
server = relationship(Server, cascade='all')
server = relationship(
Server,
backref=backref('service', uselist=False),
single_parent=True,
cascade="all, delete-orphan",
)
pid = Column(Integer)
def new_api_token(self, token=None, **kwargs):
@@ -300,7 +311,46 @@ class Service(Base):
return db.query(cls).filter(cls.name == name).first()
class Hashed(object):
class Expiring:
"""Mixin for expiring entries
Subclass must define at least expires_at property,
which should be unix timestamp or datetime object
"""
now = utcnow # funciton, must return float timestamp or datetime
expires_at = None # must be defined
@property
def expires_in(self):
"""Property returning expiration in seconds from now
or None
"""
if self.expires_at:
delta = self.expires_at - self.now()
if isinstance(delta, timedelta):
delta = delta.total_seconds()
return delta
else:
return None
@classmethod
def purge_expired(cls, db):
"""Purge expired API Tokens from the database"""
now = cls.now()
deleted = False
for obj in (
db.query(cls).filter(cls.expires_at != None).filter(cls.expires_at < now)
):
app_log.debug("Purging expired %s", obj)
deleted = True
db.delete(obj)
if deleted:
db.commit()
class Hashed(Expiring):
"""Mixin for tables with hashed tokens"""
prefix_length = 4
@@ -357,11 +407,21 @@ class Hashed(object):
"""Start the query for matching token.
Returns an SQLAlchemy query already filtered by prefix-matches.
.. versionchanged:: 1.2
Excludes expired matches.
"""
prefix = token[: cls.prefix_length]
# since we can't filter on hashed values, filter on prefix
# so we aren't comparing with all tokens
return db.query(cls).filter(bindparam('prefix', prefix).startswith(cls.prefix))
prefix_match = db.query(cls).filter(
bindparam('prefix', prefix).startswith(cls.prefix)
)
prefix_match = prefix_match.filter(
or_(cls.expires_at == None, cls.expires_at >= cls.now())
)
return prefix_match
@classmethod
def find(cls, db, token):
@@ -397,6 +457,7 @@ class APIToken(Hashed, Base):
return 'a%i' % self.id
# token metadata for bookkeeping
now = datetime.utcnow # for expiry
created = Column(DateTime, default=datetime.utcnow)
expires_at = Column(DateTime, default=None, nullable=True)
last_activity = Column(DateTime)
@@ -417,20 +478,6 @@ class APIToken(Hashed, Base):
cls=self.__class__.__name__, pre=self.prefix, kind=kind, name=name
)
@classmethod
def purge_expired(cls, db):
"""Purge expired API Tokens from the database"""
now = utcnow()
deleted = False
for token in (
db.query(cls).filter(cls.expires_at != None).filter(cls.expires_at < now)
):
app_log.debug("Purging expired %s", token)
deleted = True
db.delete(token)
if deleted:
db.commit()
@classmethod
def find(cls, db, token, *, kind=None):
"""Find a token object by value.
@@ -441,9 +488,6 @@ class APIToken(Hashed, Base):
`kind='service'` only returns API tokens for services
"""
prefix_match = cls.find_prefix(db, token)
prefix_match = prefix_match.filter(
or_(cls.expires_at == None, cls.expires_at >= utcnow())
)
if kind == 'user':
prefix_match = prefix_match.filter(cls.user_id != None)
elif kind == 'service':
@@ -486,7 +530,7 @@ class APIToken(Hashed, Base):
assert service.id is not None
orm_token.service = service
if expires_in is not None:
orm_token.expires_at = utcnow() + timedelta(seconds=expires_in)
orm_token.expires_at = cls.now() + timedelta(seconds=expires_in)
db.add(orm_token)
db.commit()
return token
@@ -510,6 +554,10 @@ class OAuthAccessToken(Hashed, Base):
__tablename__ = 'oauth_access_tokens'
id = Column(Integer, primary_key=True, autoincrement=True)
@staticmethod
def now():
return datetime.utcnow().timestamp()
@property
def api_id(self):
return 'o%i' % self.id
@@ -536,11 +584,12 @@ class OAuthAccessToken(Hashed, Base):
last_activity = Column(DateTime, nullable=True)
def __repr__(self):
return "<{cls}('{prefix}...', client_id={client_id!r}, user={user!r}>".format(
return "<{cls}('{prefix}...', client_id={client_id!r}, user={user!r}, expires_in={expires_in}>".format(
cls=self.__class__.__name__,
client_id=self.client_id,
user=self.user and self.user.name,
prefix=self.prefix,
expires_in=self.expires_in,
)
@classmethod
@@ -557,8 +606,9 @@ class OAuthAccessToken(Hashed, Base):
return orm_token
class OAuthCode(Base):
class OAuthCode(Expiring, Base):
__tablename__ = 'oauth_codes'
id = Column(Integer, primary_key=True, autoincrement=True)
client_id = Column(
Unicode(255), ForeignKey('oauth_clients.identifier', ondelete='CASCADE')
@@ -570,6 +620,19 @@ class OAuthCode(Base):
# state = Column(Unicode(1023))
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
@staticmethod
def now():
return datetime.utcnow().timestamp()
@classmethod
def find(cls, db, code):
return (
db.query(cls)
.filter(cls.code == code)
.filter(or_(cls.expires_at == None, cls.expires_at >= cls.now()))
.first()
)
class OAuthClient(Base):
__tablename__ = 'oauth_clients'
@@ -623,7 +686,10 @@ def _expire_relationship(target, relationship_prop):
return
# many-to-many and one-to-many have a list of peers
# many-to-one has only one
if relationship_prop.direction is interfaces.MANYTOONE:
if (
relationship_prop.direction is interfaces.MANYTOONE
or not relationship_prop.uselist
):
peers = [peers]
for obj in peers:
if inspect(obj).persistent:

213
jupyterhub/pagination.py Normal file
View File

@@ -0,0 +1,213 @@
"""Basic class to manage pagination utils."""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
from traitlets import Bool
from traitlets import default
from traitlets import Integer
from traitlets import observe
from traitlets import Unicode
from traitlets import validate
from traitlets.config import Configurable
class Pagination(Configurable):
# configurable options
default_per_page = Integer(
100,
config=True,
help="Default number of entries per page for paginated results.",
)
max_per_page = Integer(
250,
config=True,
help="Maximum number of entries per page for paginated results.",
)
# state variables
url = Unicode("")
page = Integer(1)
per_page = Integer(1, min=1)
@default("per_page")
def _default_per_page(self):
return self.default_per_page
@validate("per_page")
def _limit_per_page(self, proposal):
if self.max_per_page and proposal.value > self.max_per_page:
return self.max_per_page
if proposal.value <= 1:
return 1
return proposal.value
@observe("max_per_page")
def _apply_max(self, change):
if change.new:
self.per_page = min(change.new, self.per_page)
total = Integer(0)
total_pages = Integer(0)
@default("total_pages")
def _calculate_total_pages(self):
total_pages = self.total // self.per_page
if self.total % self.per_page:
# there's a remainder, add 1
total_pages += 1
return total_pages
@observe("per_page", "total")
def _update_total_pages(self, change):
"""Update total_pages when per_page or total is changed"""
self.total_pages = self._calculate_total_pages()
separator = Unicode("...")
def get_page_args(self, handler):
"""
This method gets the arguments used in the webpage to configurate the pagination
In case of no arguments, it uses the default values from this class
Returns:
- page: The page requested for paginating or the default value (1)
- per_page: The number of items to return in this page. No more than max_per_page
- offset: The offset to consider when managing pagination via the ORM
"""
page = handler.get_argument("page", 1)
per_page = handler.get_argument("per_page", self.default_per_page)
try:
self.per_page = int(per_page)
except Exception:
self.per_page = self._default_per_page
try:
self.page = int(page)
if self.page < 1:
self.page = 1
except:
self.page = 1
return self.page, self.per_page, self.per_page * (self.page - 1)
@property
def info(self):
"""Get the pagination information."""
start = 1 + (self.page - 1) * self.per_page
end = start + self.per_page - 1
if end > self.total:
end = self.total
if start > self.total:
start = self.total
return {'total': self.total, 'start': start, 'end': end}
def calculate_pages_window(self):
"""Calculates the set of pages to render later in links() method.
It returns the list of pages to render via links for the pagination
By default, as we've observed in other applications, we're going to render
only a finite and predefined number of pages, avoiding visual fatigue related
to a long list of pages. By default, we render 7 pages plus some inactive links with the characters '...'
to point out that there are other pages that aren't explicitly rendered.
The primary way of work is to provide current webpage and 5 next pages, the last 2 ones
(in case the current page + 5 does not overflow the total lenght of pages) and the first one for reference.
"""
before_page = 2
after_page = 2
window_size = before_page + after_page + 1
# Add 1 to total_pages since our starting page is 1 and not 0
last_page = self.total_pages
pages = []
# will default window + start, end fit without truncation?
if self.total_pages > window_size + 2:
if self.page - before_page > 1:
# before_page will not reach page 1
pages.append(1)
if self.page - before_page > 2:
# before_page will not reach page 2, need separator
pages.append(self.separator)
pages.extend(range(max(1, self.page - before_page), self.page))
# we now have up to but not including self.page
if self.page + after_page + 1 >= last_page:
# after_page gets us to the end
pages.extend(range(self.page, last_page + 1))
else:
# add full after_page entries
pages.extend(range(self.page, self.page + after_page + 1))
# add separator *if* this doesn't get to last page - 1
if self.page + after_page < last_page - 1:
pages.append(self.separator)
pages.append(last_page)
return pages
else:
# everything will fit, nothing to think about
# always return at least one page
return list(range(1, last_page + 1)) or [1]
@property
def links(self):
"""Get the links for the pagination.
Getting the input from calculate_pages_window(), generates the HTML code
for the pages to render, plus the arrows to go onwards and backwards (if needed).
"""
if self.total_pages == 1:
return []
pages_to_render = self.calculate_pages_window()
links = ['<nav>']
links.append('<ul class="pagination">')
if self.page > 1:
prev_page = self.page - 1
links.append(
'<li><a href="?page={prev_page}">«</a></li>'.format(prev_page=prev_page)
)
else:
links.append(
'<li class="disabled"><span><span aria-hidden="true">«</span></span></li>'
)
for page in list(pages_to_render):
if page == self.page:
links.append(
'<li class="active"><span>{page}<span class="sr-only">(current)</span></span></li>'.format(
page=page
)
)
elif page == self.separator:
links.append(
'<li class="disabled"><span> <span aria-hidden="true">{separator}</span></span></li>'.format(
separator=self.separator
)
)
else:
links.append(
'<li><a href="?page={page}">{page}</a></li>'.format(page=page)
)
if self.page >= 1 and self.page < self.total_pages:
next_page = self.page + 1
links.append(
'<li><a href="?page={next_page}">»</a></li>'.format(next_page=next_page)
)
else:
links.append(
'<li class="disabled"><span><span aria-hidden="true">»</span></span></li>'
)
links.append('</ul>')
links.append('</nav>')
return ''.join(links)

View File

@@ -24,7 +24,6 @@ import time
from functools import wraps
from subprocess import Popen
from urllib.parse import quote
from urllib.parse import urlparse
from tornado import gen
from tornado.httpclient import AsyncHTTPClient
@@ -44,6 +43,7 @@ from . import utils
from .metrics import CHECK_ROUTES_DURATION_SECONDS
from .metrics import PROXY_POLL_DURATION_SECONDS
from .objects import Server
from .utils import exponential_backoff
from .utils import make_ssl_context
from .utils import url_path_join
from jupyterhub.traitlets import Command
@@ -497,6 +497,19 @@ class ConfigurableHTTPProxy(Proxy):
if not psutil.pid_exists(pid):
raise ProcessLookupError
try:
process = psutil.Process(pid)
if self.command and self.command[0]:
process_cmd = process.cmdline()
if process_cmd and not any(
self.command[0] in clause for clause in process_cmd
):
raise ProcessLookupError
except (psutil.AccessDenied, psutil.NoSuchProcess):
# If there is a process at the proxy's PID but we don't have permissions to see it,
# then it is unlikely to actually be the proxy.
raise ProcessLookupError
else:
os.kill(pid, 0)
@@ -692,8 +705,17 @@ class ConfigurableHTTPProxy(Proxy):
parent = psutil.Process(pid)
children = parent.children(recursive=True)
for child in children:
child.kill()
psutil.wait_procs(children, timeout=5)
child.terminate()
gone, alive = psutil.wait_procs(children, timeout=5)
for p in alive:
p.kill()
# Clear the shell, too, if it still exists.
try:
parent.terminate()
parent.wait(timeout=5)
parent.kill()
except psutil.NoSuchProcess:
pass
def _terminate(self):
"""Terminate our process"""
@@ -769,9 +791,35 @@ class ConfigurableHTTPProxy(Proxy):
method=method,
headers={'Authorization': 'token {}'.format(self.auth_token)},
body=body,
connect_timeout=3, # default: 20s
request_timeout=10, # default: 20s
)
async def _wait_for_api_request():
try:
async with self.semaphore:
result = await client.fetch(req)
return await client.fetch(req)
except HTTPError as e:
# Retry on potentially transient errors in CHP, typically
# numbered 500 and up. Note that CHP isn't able to emit 429
# errors.
if e.code >= 500:
self.log.warning(
"api_request to the proxy failed with status code {}, retrying...".format(
e.code
)
)
return False # a falsy return value make exponential_backoff retry
else:
self.log.error("api_request to proxy failed: {0}".format(e))
# An unhandled error here will help the hub invoke cleanup logic
raise
result = await exponential_backoff(
_wait_for_api_request,
'Repeated api_request to proxy path "{}" failed.'.format(path),
timeout=30,
)
return result
async def add_route(self, routespec, target, data):

View File

@@ -371,9 +371,13 @@ class HubAuth(SingletonConfigurable):
)
app_log.warning(r.text)
msg = "Failed to check authorization"
# pass on error_description from oauth failure
# pass on error from oauth failure
try:
description = r.json().get("error_description")
response = r.json()
# prefer more specific 'error_description', fallback to 'error'
description = response.get(
"error_description", response.get("error", "Unknown error")
)
except Exception:
pass
else:
@@ -860,15 +864,15 @@ class HubAuthenticated(object):
if kind == 'service':
# it's a service, check hub_services
if self.hub_services and name in self.hub_services:
app_log.debug("Allowing whitelisted Hub service %s", name)
app_log.debug("Allowing Hub service %s", name)
return model
else:
app_log.warning("Not allowing Hub service %s", name)
raise UserNotAllowed(model)
if self.hub_users and name in self.hub_users:
# user in whitelist
app_log.debug("Allowing whitelisted Hub user %s", name)
# user in allowed list
app_log.debug("Allowing Hub user %s", name)
return model
elif self.hub_groups and set(model['groups']).intersection(self.hub_groups):
allowed_groups = set(model['groups']).intersection(self.hub_groups)
@@ -877,7 +881,7 @@ class HubAuthenticated(object):
name,
','.join(sorted(allowed_groups)),
)
# group in whitelist
# group in allowed list
return model
else:
app_log.warning("Not allowing Hub user %s", name)

View File

@@ -201,6 +201,10 @@ class Service(LoggingConfigurable):
"""
).tag(input=True)
display = Bool(
True, help="""Whether to list the service on the JupyterHub UI"""
).tag(input=True)
oauth_no_confirm = Bool(
False,
help="""Skip OAuth confirmation when users access this service.
@@ -342,7 +346,7 @@ class Service(LoggingConfigurable):
env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url
hub = self.hub
if self.hub.ip in ('0.0.0.0', ''):
if self.hub.ip in ('', '0.0.0.0', '::'):
# if the Hub is listening on all interfaces,
# tell services to connect via localhost
# since they are always local subprocesses

View File

@@ -0,0 +1,13 @@
"""JupyterHub single-user server entrypoints
Contains default notebook-app subclass and mixins
"""
from .app import main
from .app import SingleUserNotebookApp
from .mixins import HubAuthenticatedHandler
from .mixins import make_singleuser_app
# backward-compatibility
JupyterHubLoginHandler = SingleUserNotebookApp.login_handler_class
JupyterHubLogoutHandler = SingleUserNotebookApp.logout_handler_class
OAuthCallbackHandler = SingleUserNotebookApp.oauth_callback_handler_class

View File

@@ -0,0 +1,4 @@
from .app import main
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,20 @@
"""Make a single-user app based on the environment:
- $JUPYTERHUB_SINGLEUSER_APP, the base Application class, to be wrapped in JupyterHub authentication.
default: notebook.notebookapp.NotebookApp
"""
import os
from traitlets import import_item
from .mixins import make_singleuser_app
JUPYTERHUB_SINGLEUSER_APP = (
os.environ.get("JUPYTERHUB_SINGLEUSER_APP") or "notebook.notebookapp.NotebookApp"
)
App = import_item(JUPYTERHUB_SINGLEUSER_APP)
SingleUserNotebookApp = make_singleuser_app(App)
main = SingleUserNotebookApp.launch_instance

View File

@@ -1,8 +1,15 @@
#!/usr/bin/env python
"""Extend regular notebook server to be aware of multiuser things."""
"""Mixins to regular notebook server to add JupyterHub auth.
Meant to be compatible with jupyter_server and classic notebook
Use make_singleuser_app to create a compatible Application class
with JupyterHub authentication mixins enabled.
"""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import asyncio
import importlib
import json
import os
import random
@@ -19,38 +26,29 @@ from tornado.httpclient import AsyncHTTPClient
from tornado.httpclient import HTTPRequest
from tornado.web import HTTPError
from tornado.web import RequestHandler
from traitlets import Any
from traitlets import Bool
from traitlets import Bytes
from traitlets import CUnicode
from traitlets import default
from traitlets import import_item
from traitlets import Integer
from traitlets import observe
from traitlets import TraitError
from traitlets import Unicode
from traitlets import validate
from traitlets.config import Configurable
try:
import notebook
except ImportError:
raise ImportError("JupyterHub single-user server requires notebook >= 4.0")
from traitlets import (
Any,
Bool,
Bytes,
Integer,
Unicode,
CUnicode,
default,
observe,
validate,
TraitError,
)
from notebook.notebookapp import (
NotebookApp,
aliases as notebook_aliases,
flags as notebook_flags,
)
from notebook.auth.login import LoginHandler
from notebook.auth.logout import LogoutHandler
from notebook.base.handlers import IPythonHandler
from ._version import __version__, _check_version
from .log import log_request
from .services.auth import HubOAuth, HubOAuthenticated, HubOAuthCallbackHandler
from .utils import isoformat, url_path_join, make_ssl_context, exponential_backoff
from .._version import __version__
from .._version import _check_version
from ..log import log_request
from ..services.auth import HubOAuth
from ..services.auth import HubOAuthCallbackHandler
from ..services.auth import HubOAuthenticated
from ..utils import exponential_backoff
from ..utils import isoformat
from ..utils import make_ssl_context
from ..utils import url_path_join
# Authenticate requests with the Hub
@@ -80,7 +78,7 @@ class HubAuthenticatedHandler(HubOAuthenticated):
return set()
class JupyterHubLoginHandler(LoginHandler):
class JupyterHubLoginHandlerMixin:
"""LoginHandler that hooks up Hub authentication"""
@staticmethod
@@ -113,7 +111,7 @@ class JupyterHubLoginHandler(LoginHandler):
return
class JupyterHubLogoutHandler(LogoutHandler):
class JupyterHubLogoutHandlerMixin:
def get(self):
self.settings['hub_auth'].clear_cookie(self)
self.redirect(
@@ -122,7 +120,7 @@ class JupyterHubLogoutHandler(LogoutHandler):
)
class OAuthCallbackHandler(HubOAuthCallbackHandler, IPythonHandler):
class OAuthCallbackHandlerMixin(HubOAuthCallbackHandler):
"""Mixin IPythonHandler to get the right error pages, etc."""
@property
@@ -131,9 +129,7 @@ class OAuthCallbackHandler(HubOAuthCallbackHandler, IPythonHandler):
# register new hub related command-line aliases
aliases = dict(notebook_aliases)
aliases.update(
{
aliases = {
'user': 'SingleUserNotebookApp.user',
'group': 'SingleUserNotebookApp.group',
'cookie-name': 'HubAuth.cookie_name',
@@ -142,16 +138,13 @@ aliases.update(
'hub-api-url': 'SingleUserNotebookApp.hub_api_url',
'base-url': 'SingleUserNotebookApp.base_url',
}
)
flags = dict(notebook_flags)
flags.update(
{
flags = {
'disable-user-config': (
{'SingleUserNotebookApp': {'disable_user_config': True}},
"Disable user-controlled configuration of the notebook server.",
)
}
)
page_template = """
{% extends "templates/page.html" %}
@@ -216,21 +209,29 @@ def _exclude_home(path_list):
yield p
class SingleUserNotebookApp(NotebookApp):
class SingleUserNotebookAppMixin(Configurable):
"""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.
Meant to be invoked by JupyterHub Spawners, not directly.
"""
)
examples = ""
subcommands = {}
version = __version__
classes = NotebookApp.classes + [HubOAuth]
# must be set in mixin subclass
# make_singleuser_app sets these
# aliases = aliases
# flags = flags
# login_handler_class = JupyterHubLoginHandler
# logout_handler_class = JupyterHubLogoutHandler
# oauth_callback_handler_class = OAuthCallbackHandler
# classes = NotebookApp.classes + [HubOAuth]
# disable single-user app's localhost checking
allow_remote_access = True
@@ -323,16 +324,12 @@ class SingleUserNotebookApp(NotebookApp):
return url.hostname
return '127.0.0.1'
aliases = aliases
flags = flags
# disble some single-user configurables
# disable some single-user configurables
token = ''
open_browser = False
quit_button = 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
)
@@ -381,11 +378,11 @@ class SingleUserNotebookApp(NotebookApp):
# disable config-migration when user config is disabled
return
else:
super(SingleUserNotebookApp, self).migrate_config()
super().migrate_config()
@property
def config_file_paths(self):
path = super(SingleUserNotebookApp, self).config_file_paths
path = super().config_file_paths
if self.disable_user_config:
# filter out user-writable config dirs if user config is disabled
@@ -394,7 +391,7 @@ class SingleUserNotebookApp(NotebookApp):
@property
def nbextensions_path(self):
path = super(SingleUserNotebookApp, self).nbextensions_path
path = super().nbextensions_path
if self.disable_user_config:
path = list(_exclude_home(path))
@@ -490,7 +487,7 @@ class SingleUserNotebookApp(NotebookApp):
# protect against mixed timezone comparisons
if not last_activity.tzinfo:
# assume naive timestamps are utc
self.log.warning("last activity is using naïve timestamps")
self.log.warning("last activity is using naive timestamps")
last_activity = last_activity.replace(tzinfo=timezone.utc)
if self._last_activity_sent and last_activity < self._last_activity_sent:
@@ -562,7 +559,7 @@ class SingleUserNotebookApp(NotebookApp):
# start by hitting Hub to check version
ioloop.IOLoop.current().run_sync(self.check_hub_version)
ioloop.IOLoop.current().add_callback(self.keep_activity_updated)
super(SingleUserNotebookApp, self).start()
super().start()
def init_hub_auth(self):
api_token = None
@@ -610,12 +607,17 @@ class SingleUserNotebookApp(NotebookApp):
'Content-Security-Policy',
';'.join(["frame-ancestors 'self'", "report-uri " + csp_report_uri]),
)
super(SingleUserNotebookApp, self).init_webapp()
super().init_webapp()
# add OAuth callback
self.web_app.add_handlers(
r".*$",
[(urlparse(self.hub_auth.oauth_redirect_uri).path, OAuthCallbackHandler)],
[
(
urlparse(self.hub_auth.oauth_redirect_uri).path,
self.oauth_callback_handler_class,
)
],
)
# apply X-JupyterHub-Version to *all* request handlers (even redirects)
@@ -656,9 +658,82 @@ class SingleUserNotebookApp(NotebookApp):
env.loader = ChoiceLoader([FunctionLoader(get_page), orig_loader])
def main(argv=None):
return SingleUserNotebookApp.launch_instance(argv)
def detect_base_package(App):
"""Detect the base package for an App class
Will return 'notebook' or 'jupyter_server'
based on which package App subclasses from.
Will return None if neither is identified (e.g. fork package, or duck-typing).
"""
# guess notebook or jupyter_server based on App class inheritance
for cls in App.mro():
pkg = cls.__module__.split(".", 1)[0]
if pkg in {"notebook", "jupyter_server"}:
return pkg
return None
if __name__ == "__main__":
main()
def make_singleuser_app(App):
"""Make and return a singleuser notebook app
given existing notebook or jupyter_server Application classes,
mix-in jupyterhub auth.
Instances of App must have the following attributes defining classes:
- .login_handler_class
- .logout_handler_class
- .base_handler_class (only required if not a subclass of the default app
in jupyter_server or notebook)
App should be a subclass of `notebook.notebookapp.NotebookApp`
or `jupyter_server.serverapp.ServerApp`.
"""
empty_parent_app = App()
# detect base classes
LoginHandler = empty_parent_app.login_handler_class
LogoutHandler = empty_parent_app.logout_handler_class
BaseHandler = getattr(empty_parent_app, "base_handler_class", None)
if BaseHandler is None:
pkg = detect_base_package(App)
if pkg == "jupyter_server":
BaseHandler = import_item("jupyter_server.base.handlers.JupyterHandler")
elif pkg == "notebook":
BaseHandler = import_item("notebook.base.handlers.IPythonHandler")
else:
raise ValueError(
"{}.base_handler_class must be defined".format(App.__name__)
)
# create Handler classes from mixins + bases
class JupyterHubLoginHandler(JupyterHubLoginHandlerMixin, LoginHandler):
pass
class JupyterHubLogoutHandler(JupyterHubLogoutHandlerMixin, LogoutHandler):
pass
class OAuthCallbackHandler(OAuthCallbackHandlerMixin, BaseHandler):
pass
# create merged aliases & flags
merged_aliases = {}
merged_aliases.update(empty_parent_app.aliases or {})
merged_aliases.update(aliases)
merged_flags = {}
merged_flags.update(empty_parent_app.flags or {})
merged_flags.update(flags)
# create mixed-in App class, bringing it all together
class SingleUserNotebookApp(SingleUserNotebookAppMixin, App):
aliases = merged_aliases
flags = merged_flags
classes = empty_parent_app.classes + [HubOAuth]
login_handler_class = JupyterHubLoginHandler
logout_handler_class = JupyterHubLogoutHandler
oauth_callback_handler_class = OAuthCallbackHandler
return SingleUserNotebookApp

View File

@@ -4,8 +4,6 @@ Contains base Spawner class & default implementation
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import ast
import asyncio
import errno
import json
import os
import pipes
@@ -384,6 +382,37 @@ class Spawner(LoggingConfigurable):
"""
return form_data
def options_from_query(self, query_data):
"""Interpret query arguments passed to /spawn
Query arguments will always arrive as a dict of unicode strings.
Override this function to understand single-values, numbers, etc.
By default, options_from_form is called from this function. You can however override
this function if you need to process the query arguments differently.
This should coerce form data into the structure expected by self.user_options,
which must be a dict, and should be JSON-serializeable,
though it can contain bytes in addition to standard JSON data types.
This method should not have any side effects.
Any handling of `user_options` should be done in `.start()`
to ensure consistent behavior across servers
spawned via the API and form submission page.
Instances will receive this data on self.user_options, after passing through this function,
prior to `Spawner.start`.
.. versionadded:: 1.2
user_options are persisted in the JupyterHub database to be reused
on subsequent spawns if no options are given.
user_options is serialized to JSON as part of this persistence
(with additional support for bytes in case of uploaded file data),
and any non-bytes non-jsonable values will be replaced with None
if the user_options are re-used.
"""
return self.options_from_form(query_data)
user_options = Dict(
help="""
Dict of user specified options for the user's spawned instance of a single-user server.
@@ -402,11 +431,12 @@ class Spawner(LoggingConfigurable):
'VIRTUAL_ENV',
'LANG',
'LC_ALL',
'JUPYTERHUB_SINGLEUSER_APP',
],
help="""
Whitelist of environment variables for the single-user server to inherit from the JupyterHub process.
List of environment variables for the single-user server to inherit from the JupyterHub process.
This whitelist is used to ensure that sensitive information in the JupyterHub process's environment
This list is used to ensure that sensitive information in the JupyterHub process's environment
(such as `CONFIGPROXY_AUTH_TOKEN`) is not passed to the single-user server's process.
""",
).tag(config=True)
@@ -425,7 +455,7 @@ class Spawner(LoggingConfigurable):
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`
- The JupyterHub process' environment variables that are listed in `env_keep`
- Variables to establish contact between the single-user notebook and the hub (such as JUPYTERHUB_API_TOKEN)
The `environment` configurable should be set by JupyterHub administrators to add
@@ -436,6 +466,11 @@ class Spawner(LoggingConfigurable):
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!
.. versionchanged:: 1.2
environment from this configuration has highest priority,
allowing override of 'default' env variables,
such as JUPYTERHUB_API_URL.
"""
).tag(config=True)
@@ -709,16 +744,6 @@ class Spawner(LoggingConfigurable):
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
@@ -766,6 +791,18 @@ class Spawner(LoggingConfigurable):
env['JUPYTERHUB_SSL_CERTFILE'] = self.cert_paths['certfile']
env['JUPYTERHUB_SSL_CLIENT_CA'] = self.cert_paths['cafile']
# env overrides from config. If the value is a callable, it will be called with
# one parameter - the current spawner instance - and the return value
# will be assigned to the environment variable. This will be called at
# spawn time.
# Called last to ensure highest priority, in case of overriding other
# 'default' variables like the API url
for key, value in self.environment.items():
if callable(value):
env[key] = value(self)
else:
env[key] = value
return env
async def get_url(self):
@@ -910,10 +947,9 @@ class Spawner(LoggingConfigurable):
but not necessarily by the notebook server.
Returns:
dict: a list (potentially altered) of paths for key, cert,
and CA.
These paths should be resolvable and readable
by the notebook server to be launched.
dict: a list (potentially altered) of paths for key, cert, and CA.
These paths should be resolvable and readable by the notebook
server to be launched.
`.move_certs` is called after certs for the singleuser notebook have
@@ -952,7 +988,9 @@ class Spawner(LoggingConfigurable):
args.append('--notebook-dir=%s' % _quote_safe(notebook_dir))
if self.default_url:
default_url = self.format_string(self.default_url)
args.append('--NotebookApp.default_url=%s' % _quote_safe(default_url))
args.append(
'--SingleUserNotebookApp.default_url=%s' % _quote_safe(default_url)
)
if self.debug:
args.append('--debug')
@@ -1580,5 +1618,5 @@ class SimpleLocalProcessSpawner(LocalProcessSpawner):
return env
def move_certs(self, paths):
"""No-op for installing certs"""
"""No-op for installing certs."""
return paths

View File

@@ -173,6 +173,9 @@ class FormSpawner(MockSpawner):
options['energy'] = form_data['energy'][0]
if 'hello_file' in form_data:
options['hello'] = form_data['hello_file'][0]
if 'illegal_argument' in form_data:
raise ValueError("You are not allowed to specify 'illegal_argument'")
return options
@@ -391,6 +394,17 @@ class MockSingleUserServer(SingleUserNotebookApp):
class StubSingleUserSpawner(MockSpawner):
"""Spawner that starts a MockSingleUserServer in a thread."""
@default("default_url")
def _default_url(self):
"""Use a default_url that any jupyter server will provide
Should be:
- authenticated, so we are testing auth
- always available (i.e. in base ServerApp and NotebookApp
"""
return "/api/status"
_thread = None
@gen.coroutine

View File

@@ -0,0 +1,17 @@
"""Example JupyterServer app subclass"""
from jupyter_server.base.handlers import JupyterHandler
from jupyter_server.serverapp import ServerApp
from tornado import web
class TreeHandler(JupyterHandler):
@web.authenticated
def get(self):
self.write("OK!")
class MockServerApp(ServerApp):
def initialize(self, argv=None):
self.default_url = "/tree"
super().initialize(argv)
self.web_app.add_handlers(".*$", [(self.base_url + "tree/?", TreeHandler)])

View File

@@ -1,5 +1,4 @@
"""Tests for the REST API."""
import asyncio
import json
import re
import sys
@@ -1514,6 +1513,7 @@ async def test_get_services(app, mockservice_url):
'prefix': mockservice.server.base_url,
'url': mockservice.url,
'info': {},
'display': True,
}
}
@@ -1538,6 +1538,7 @@ async def test_get_service(app, mockservice_url):
'prefix': mockservice.server.base_url,
'url': mockservice.url,
'info': {},
'display': True,
}
r = await api_request(

View File

@@ -7,13 +7,11 @@ import time
from subprocess import check_output
from subprocess import PIPE
from subprocess import Popen
from subprocess import run
from tempfile import NamedTemporaryFile
from tempfile import TemporaryDirectory
from unittest.mock import patch
import pytest
from tornado import gen
from traitlets.config import Config
from .. import orm
@@ -93,7 +91,7 @@ def test_generate_config():
os.remove(cfg_file)
assert cfg_file in out
assert 'Spawner.cmd' in cfg_text
assert 'Authenticator.whitelist' in cfg_text
assert 'Authenticator.allowed_users' in cfg_text
async def test_init_tokens(request):

View File

@@ -1,11 +1,12 @@
"""Tests for PAM authentication"""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import os
import logging
from unittest import mock
import pytest
from requests import HTTPError
from traitlets.config import Config
from .mocking import MockPAMAuthenticator
from .mocking import MockStructGroup
@@ -137,8 +138,8 @@ async def test_pam_auth_admin_groups():
assert authorized['admin'] is False
async def test_pam_auth_whitelist():
authenticator = MockPAMAuthenticator(whitelist={'wash', 'kaylee'})
async def test_pam_auth_allowed():
authenticator = MockPAMAuthenticator(allowed_users={'wash', 'kaylee'})
authorized = await authenticator.get_authenticated_user(
None, {'username': 'kaylee', 'password': 'kaylee'}
)
@@ -155,11 +156,11 @@ async def test_pam_auth_whitelist():
assert authorized is None
async def test_pam_auth_group_whitelist():
async def test_pam_auth_allowed_groups():
def getgrnam(name):
return MockStructGroup('grp', ['kaylee'])
authenticator = MockPAMAuthenticator(group_whitelist={'group'})
authenticator = MockPAMAuthenticator(allowed_groups={'group'})
with mock.patch.object(authenticator, '_getgrnam', getgrnam):
authorized = await authenticator.get_authenticated_user(
@@ -174,7 +175,7 @@ async def test_pam_auth_group_whitelist():
assert authorized is None
async def test_pam_auth_blacklist():
async def test_pam_auth_blocked():
# Null case compared to next case
authenticator = MockPAMAuthenticator()
authorized = await authenticator.get_authenticated_user(
@@ -183,33 +184,33 @@ async def test_pam_auth_blacklist():
assert authorized['name'] == 'wash'
# Blacklist basics
authenticator = MockPAMAuthenticator(blacklist={'wash'})
authenticator = MockPAMAuthenticator(blocked_users={'wash'})
authorized = await authenticator.get_authenticated_user(
None, {'username': 'wash', 'password': 'wash'}
)
assert authorized is None
# User in both white and blacklists: default deny. Make error someday?
# User in both allowed and blocked: default deny. Make error someday?
authenticator = MockPAMAuthenticator(
blacklist={'wash'}, whitelist={'wash', 'kaylee'}
blocked_users={'wash'}, allowed_users={'wash', 'kaylee'}
)
authorized = await authenticator.get_authenticated_user(
None, {'username': 'wash', 'password': 'wash'}
)
assert authorized is None
# User not in blacklist can log in
# User not in blocked set can log in
authenticator = MockPAMAuthenticator(
blacklist={'wash'}, whitelist={'wash', 'kaylee'}
blocked_users={'wash'}, allowed_users={'wash', 'kaylee'}
)
authorized = await authenticator.get_authenticated_user(
None, {'username': 'kaylee', 'password': 'kaylee'}
)
assert authorized['name'] == 'kaylee'
# User in whitelist, blacklist irrelevent
# User in allowed, blocked irrelevent
authenticator = MockPAMAuthenticator(
blacklist={'mal'}, whitelist={'wash', 'kaylee'}
blocked_users={'mal'}, allowed_users={'wash', 'kaylee'}
)
authorized = await authenticator.get_authenticated_user(
None, {'username': 'wash', 'password': 'wash'}
@@ -218,15 +219,16 @@ async def test_pam_auth_blacklist():
# User in neither list
authenticator = MockPAMAuthenticator(
blacklist={'mal'}, whitelist={'wash', 'kaylee'}
blocked_users={'mal'}, allowed_users={'wash', 'kaylee'}
)
authorized = await authenticator.get_authenticated_user(
None, {'username': 'simon', 'password': 'simon'}
)
assert authorized is None
# blacklist == {}
authenticator = MockPAMAuthenticator(blacklist=set(), whitelist={'wash', 'kaylee'})
authenticator = MockPAMAuthenticator(
blocked_users=set(), allowed_users={'wash', 'kaylee'}
)
authorized = await authenticator.get_authenticated_user(
None, {'username': 'kaylee', 'password': 'kaylee'}
)
@@ -253,7 +255,7 @@ async def test_deprecated_signatures():
async def test_pam_auth_no_such_group():
authenticator = MockPAMAuthenticator(group_whitelist={'nosuchcrazygroup'})
authenticator = MockPAMAuthenticator(allowed_groups={'nosuchcrazygroup'})
authorized = await authenticator.get_authenticated_user(
None, {'username': 'kaylee', 'password': 'kaylee'}
)
@@ -262,7 +264,7 @@ async def test_pam_auth_no_such_group():
async def test_wont_add_system_user():
user = orm.User(name='lioness4321')
authenticator = auth.PAMAuthenticator(whitelist={'mal'})
authenticator = auth.PAMAuthenticator(allowed_users={'mal'})
authenticator.create_system_users = False
with pytest.raises(KeyError):
await authenticator.add_user(user)
@@ -270,7 +272,7 @@ async def test_wont_add_system_user():
async def test_cant_add_system_user():
user = orm.User(name='lioness4321')
authenticator = auth.PAMAuthenticator(whitelist={'mal'})
authenticator = auth.PAMAuthenticator(allowed_users={'mal'})
authenticator.add_user_cmd = ['jupyterhub-fake-command']
authenticator.create_system_users = True
@@ -296,7 +298,7 @@ async def test_cant_add_system_user():
async def test_add_system_user():
user = orm.User(name='lioness4321')
authenticator = auth.PAMAuthenticator(whitelist={'mal'})
authenticator = auth.PAMAuthenticator(allowed_users={'mal'})
authenticator.create_system_users = True
authenticator.add_user_cmd = ['echo', '/home/USERNAME']
@@ -317,13 +319,13 @@ async def test_add_system_user():
async def test_delete_user():
user = orm.User(name='zoe')
a = MockPAMAuthenticator(whitelist={'mal'})
a = MockPAMAuthenticator(allowed_users={'mal'})
assert 'zoe' not in a.whitelist
assert 'zoe' not in a.allowed_users
await a.add_user(user)
assert 'zoe' in a.whitelist
assert 'zoe' in a.allowed_users
a.delete_user(user)
assert 'zoe' not in a.whitelist
assert 'zoe' not in a.allowed_users
def test_urls():
@@ -461,3 +463,55 @@ async def test_post_auth_hook():
)
assert authorized['testkey'] == 'testvalue'
class MyAuthenticator(auth.Authenticator):
def check_whitelist(self, username, authentication=None):
return username == "subclass-allowed"
def test_deprecated_config(caplog):
cfg = Config()
cfg.Authenticator.whitelist = {'user'}
log = logging.getLogger("testlog")
authenticator = auth.Authenticator(config=cfg, log=log)
assert caplog.record_tuples == [
(
log.name,
logging.WARNING,
'Authenticator.whitelist is deprecated in JupyterHub 1.2, use '
'Authenticator.allowed_users instead',
)
]
assert authenticator.allowed_users == {'user'}
def test_deprecated_methods():
cfg = Config()
cfg.Authenticator.whitelist = {'user'}
authenticator = auth.Authenticator(config=cfg)
assert authenticator.check_allowed("user")
with pytest.deprecated_call():
assert authenticator.check_whitelist("user")
assert not authenticator.check_allowed("otheruser")
with pytest.deprecated_call():
assert not authenticator.check_whitelist("otheruser")
def test_deprecated_config_subclass():
cfg = Config()
cfg.MyAuthenticator.whitelist = {'user'}
with pytest.deprecated_call():
authenticator = MyAuthenticator(config=cfg)
assert authenticator.allowed_users == {'user'}
def test_deprecated_methods_subclass():
with pytest.deprecated_call():
authenticator = MyAuthenticator()
assert authenticator.check_allowed("subclass-allowed")
assert authenticator.check_whitelist("subclass-allowed")
assert not authenticator.check_allowed("otheruser")
assert not authenticator.check_whitelist("otheruser")

View File

@@ -7,7 +7,6 @@ authentication can expire in a number of ways:
- doesn't need refresh
- needs refresh and cannot be refreshed without new login
"""
import asyncio
from contextlib import contextmanager
from unittest import mock
from urllib.parse import parse_qs

View File

@@ -1,8 +1,6 @@
"""Tests for dummy authentication"""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import pytest
from jupyterhub.auth import DummyAuthenticator

View File

@@ -1,8 +1,6 @@
"""Test the JupyterHub entry point with internal ssl"""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import sys
import jupyterhub.tests.mocking
from jupyterhub.tests.test_app import *

View File

@@ -368,3 +368,28 @@ async def test_user_redirect_hook_default_server_name(
assert redirected_url.path == url_path_join(
app.base_url, 'user', username, 'terminals/1'
)
async def test_named_server_stop_server(app, username, named_servers):
server_name = "myserver"
await app.login_user(username)
user = app.users[username]
r = await api_request(app, 'users', username, 'server', method='post')
assert r.status_code == 201
assert r.text == ''
assert user.spawners[''].server
with mock.patch.object(
app.proxy, 'add_user', side_effect=Exception('mock exception')
):
r = await api_request(
app, 'users', username, 'servers', server_name, method='post'
)
r.raise_for_status()
assert r.status_code == 201
assert r.text == ''
assert user.spawners[server_name].server is None
assert user.spawners[''].server
assert user.running

View File

@@ -134,7 +134,7 @@ def test_token_expiry(db):
assert orm_token.expires_at > now + timedelta(seconds=50)
assert orm_token.expires_at < now + timedelta(seconds=70)
the_future = mock.patch(
'jupyterhub.orm.utcnow', lambda: now + timedelta(seconds=70)
'jupyterhub.orm.APIToken.now', lambda: now + timedelta(seconds=70)
)
with the_future:
found = orm.APIToken.find(db, token=token)
@@ -482,3 +482,78 @@ def test_group_delete_cascade(db):
db.delete(user1)
db.commit()
assert user1 not in group1.users
def test_expiring_api_token(app, user):
db = app.db
token = orm.APIToken.new(expires_in=30, user=user)
orm_token = orm.APIToken.find(db, token, kind='user')
assert orm_token
# purge_expired doesn't delete non-expired
orm.APIToken.purge_expired(db)
found = orm.APIToken.find(db, token)
assert found is orm_token
with mock.patch.object(
orm.APIToken, 'now', lambda: datetime.utcnow() + timedelta(seconds=60)
):
found = orm.APIToken.find(db, token)
assert found is None
assert orm_token in db.query(orm.APIToken)
orm.APIToken.purge_expired(db)
assert orm_token not in db.query(orm.APIToken)
def test_expiring_oauth_token(app, user):
db = app.db
token = "abc123"
now = orm.OAuthAccessToken.now
client = orm.OAuthClient(identifier="xxx", secret="yyy")
db.add(client)
orm_token = orm.OAuthAccessToken(
token=token,
grant_type=orm.GrantType.authorization_code,
client=client,
user=user,
expires_at=now() + 30,
)
db.add(orm_token)
db.commit()
found = orm.OAuthAccessToken.find(db, token)
assert found is orm_token
# purge_expired doesn't delete non-expired
orm.OAuthAccessToken.purge_expired(db)
found = orm.OAuthAccessToken.find(db, token)
assert found is orm_token
with mock.patch.object(orm.OAuthAccessToken, 'now', lambda: now() + 60):
found = orm.OAuthAccessToken.find(db, token)
assert found is None
assert orm_token in db.query(orm.OAuthAccessToken)
orm.OAuthAccessToken.purge_expired(db)
assert orm_token not in db.query(orm.OAuthAccessToken)
def test_expiring_oauth_code(app, user):
db = app.db
code = "abc123"
now = orm.OAuthCode.now
orm_code = orm.OAuthCode(code=code, expires_at=now() + 30)
db.add(orm_code)
db.commit()
found = orm.OAuthCode.find(db, code)
assert found is orm_code
# purge_expired doesn't delete non-expired
orm.OAuthCode.purge_expired(db)
found = orm.OAuthCode.find(db, code)
assert found is orm_code
with mock.patch.object(orm.OAuthCode, 'now', lambda: now() + 60):
found = orm.OAuthCode.find(db, code)
assert found is None
assert orm_code in db.query(orm.OAuthCode)
orm.OAuthCode.purge_expired(db)
assert orm_code not in db.query(orm.OAuthCode)

View File

@@ -9,6 +9,7 @@ from urllib.parse import urlparse
import pytest
from bs4 import BeautifulSoup
from tornado import gen
from tornado.escape import url_escape
from tornado.httputil import url_concat
from .. import orm
@@ -30,7 +31,7 @@ async def test_root_no_auth(app):
url = ujoin(public_host(app), app.hub.base_url)
r = await async_requests.get(url)
r.raise_for_status()
assert r.url == ujoin(url, 'login')
assert r.url == url_concat(ujoin(url, 'login'), dict(next=app.hub.base_url))
async def test_root_auth(app):
@@ -255,6 +256,47 @@ async def test_spawn_page_admin(app, admin_access):
assert "Spawning server for {}".format(u.name) in r.text
async def test_spawn_with_query_arguments(app):
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
base_url = ujoin(public_host(app), app.hub.base_url)
cookies = await app.login_user('jones')
orm_u = orm.User.find(app.db, 'jones')
u = app.users[orm_u]
await u.stop()
next_url = ujoin(app.base_url, 'user/jones/tree')
r = await async_requests.get(
url_concat(
ujoin(base_url, 'spawn'), {'next': next_url, 'energy': '510keV'},
),
cookies=cookies,
)
r.raise_for_status()
assert r.history
assert u.spawner.user_options == {
'energy': '510keV',
'notspecified': 5,
}
async def test_spawn_with_query_arguments_error(app):
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
base_url = ujoin(public_host(app), app.hub.base_url)
cookies = await app.login_user('jones')
orm_u = orm.User.find(app.db, 'jones')
u = app.users[orm_u]
await u.stop()
next_url = ujoin(app.base_url, 'user/jones/tree')
r = await async_requests.get(
url_concat(
ujoin(base_url, 'spawn'),
{'next': next_url, 'energy': '510keV', 'illegal_argument': '42'},
),
cookies=cookies,
)
r.raise_for_status()
assert "You are not allowed to specify " in r.text
async def test_spawn_form(app):
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
base_url = ujoin(public_host(app), app.hub.base_url)
@@ -354,7 +396,7 @@ async def test_spawn_pending(app, username, slow_spawn):
assert page.find('div', {'class': 'progress'})
# validate event source url by consuming it
script = page.body.find('script').text
script = page.body.find('script').string
assert 'EventSource' in script
# find EventSource url in javascript
# maybe not the most robust way to check this?
@@ -475,6 +517,58 @@ async def test_user_redirect_deprecated(app, username):
)
@pytest.mark.parametrize(
'url, params, redirected_url, form_action',
[
(
# spawn?param=value
# will encode given parameters for an unauthenticated URL in the next url
# the next parameter will contain the app base URL (replaces BASE_URL in tests)
'spawn',
[('param', 'value')],
'/hub/login?next={{BASE_URL}}hub%2Fspawn%3Fparam%3Dvalue',
'/hub/login?next={{BASE_URL}}hub%2Fspawn%3Fparam%3Dvalue',
),
(
# login?param=fromlogin&next=encoded(/hub/spawn?param=value)
# will drop parameters given to the login page, passing only the next url
'login',
[('param', 'fromlogin'), ('next', '/hub/spawn?param=value')],
'/hub/login?param=fromlogin&next=%2Fhub%2Fspawn%3Fparam%3Dvalue',
'/hub/login?next=%2Fhub%2Fspawn%3Fparam%3Dvalue',
),
(
# login?param=value&anotherparam=anothervalue
# will drop parameters given to the login page, and use an empty next url
'login',
[('param', 'value'), ('anotherparam', 'anothervalue')],
'/hub/login?param=value&anotherparam=anothervalue',
'/hub/login?next=',
),
(
# login
# simplest case, accessing the login URL, gives an empty next url
'login',
[],
'/hub/login',
'/hub/login?next=',
),
],
)
async def test_login_page(app, url, params, redirected_url, form_action):
url = url_concat(url, params)
r = await get_page(url, app)
redirected_url = redirected_url.replace('{{BASE_URL}}', url_escape(app.base_url))
assert r.url.endswith(redirected_url)
# now the login.html rendered template must include the given parameters in the form
# action URL, including the next URL
page = BeautifulSoup(r.text, "html.parser")
form = page.find("form", method="post")
action = form.attrs['action']
form_action = form_action.replace('{{BASE_URL}}', url_escape(app.base_url))
assert action.endswith(form_action)
async def test_login_fail(app):
name = 'wash'
base_url = public_url(app)
@@ -505,37 +599,54 @@ async def test_login_strip(app):
@pytest.mark.parametrize(
'running, next_url, location',
'running, next_url, location, params',
[
# default URL if next not specified, for both running and not
(True, '', ''),
(False, '', ''),
(True, '', '', None),
(False, '', '', None),
# next_url is respected
(False, '/hub/admin', '/hub/admin'),
(False, '/user/other', '/hub/user/other'),
(False, '/absolute', '/absolute'),
(False, '/has?query#andhash', '/has?query#andhash'),
(False, '/hub/admin', '/hub/admin', None),
(False, '/user/other', '/hub/user/other', None),
(False, '/absolute', '/absolute', None),
(False, '/has?query#andhash', '/has?query#andhash', None),
# next_url outside is not allowed
(False, 'relative/path', ''),
(False, 'https://other.domain', ''),
(False, 'ftp://other.domain', ''),
(False, '//other.domain', ''),
(False, '///other.domain/triple', ''),
(False, '\\\\other.domain/backslashes', ''),
(False, 'relative/path', '', None),
(False, 'https://other.domain', '', None),
(False, 'ftp://other.domain', '', None),
(False, '//other.domain', '', None),
(False, '///other.domain/triple', '', None),
(False, '\\\\other.domain/backslashes', '', None),
# params are handled correctly (ignored if ?next= specified)
(
True,
'/hub/admin?left=1&right=2',
'hub/admin?left=1&right=2',
{"left": "abc"},
),
(False, '/hub/admin', 'hub/admin', [('left', 1), ('right', 2)]),
(True, '', '', {"keep": "yes"}),
(False, '', '', {"keep": "yes"}),
],
)
async def test_login_redirect(app, running, next_url, location):
async def test_login_redirect(app, running, next_url, location, params):
cookies = await app.login_user('river')
user = app.users['river']
if location:
location = ujoin(app.base_url, location)
elif running:
# location not specified,
location = user.url
if params:
location = url_concat(location, params)
else:
# use default url
location = ujoin(app.base_url, 'hub/spawn')
if params:
location = url_concat(location, params)
url = 'login'
if params:
url = url_concat(url, params)
if next_url:
if '//' not in next_url and next_url.startswith('/'):
next_url = ujoin(app.base_url, next_url, '')
@@ -550,7 +661,73 @@ async def test_login_redirect(app, running, next_url, location):
r = await get_page(url, app, cookies=cookies, allow_redirects=False)
r.raise_for_status()
assert r.status_code == 302
assert location == r.headers['Location']
assert r.headers["Location"] == location
@pytest.mark.parametrize(
'location, next, extra_params',
[
(
"{base_url}hub/spawn?a=5",
None,
{"a": "5"},
), # no ?next= given, preserve params
("/x", "/x", {"a": "5"}), # ?next=given, params ignored
(
"/x?b=10",
"/x?b=10",
{"a": "5"},
), # ?next=given with params, additional params ignored
],
)
async def test_next_url(app, user, location, next, extra_params):
params = {}
if extra_params:
params.update(extra_params)
if next:
params["next"] = next
url = url_concat("/", params)
cookies = await app.login_user("monster")
# location can be a string template
location = location.format(base_url=app.base_url)
r = await get_page(url, app, cookies=cookies, allow_redirects=False)
r.raise_for_status()
assert r.status_code == 302
assert r.headers["Location"] == location
async def test_next_url_params_sequence(app, user):
"""Test each step of / -> login -> spawn
and whether they preserve url params
"""
params = {"xyz": "5"}
# first request: root page, with params, not logged in
r = await get_page("/?xyz=5", app, allow_redirects=False)
r.raise_for_status()
location = r.headers["Location"]
# next page: login
cookies = await app.login_user(user.name)
assert location == url_concat(
ujoin(app.base_url, "/hub/login"), {"next": ujoin(app.base_url, "/hub/?xyz=5")}
)
r = await async_requests.get(
public_host(app) + location, cookies=cookies, allow_redirects=False
)
r.raise_for_status()
location = r.headers["Location"]
# after login, redirect back
assert location == ujoin(app.base_url, "/hub/?xyz=5")
r = await async_requests.get(
public_host(app) + location, cookies=cookies, allow_redirects=False
)
r.raise_for_status()
location = r.headers["Location"]
assert location == ujoin(app.base_url, "/hub/spawn?xyz=5")
async def test_auto_login(app, request):
@@ -564,14 +741,18 @@ async def test_auto_login(app, request):
)
# no auto_login: end up at /hub/login
r = await async_requests.get(base_url)
assert r.url == public_url(app, path='hub/login')
assert r.url == url_concat(
public_url(app, path="hub/login"), {"next": app.hub.base_url}
)
# enable auto_login: redirect from /hub/login to /hub/dummy
authenticator = Authenticator(auto_login=True)
authenticator.login_url = lambda base_url: ujoin(base_url, 'dummy')
with mock.patch.dict(app.tornado_settings, {'authenticator': authenticator}):
r = await async_requests.get(base_url)
assert r.url == public_url(app, path='hub/dummy')
assert r.url == url_concat(
public_url(app, path="hub/dummy"), {"next": app.hub.base_url}
)
async def test_auto_login_logout(app):
@@ -645,7 +826,7 @@ async def test_shutdown_on_logout(app, shutdown_on_logout):
assert spawner.ready == (not shutdown_on_logout)
async def test_login_no_whitelist_adds_user(app):
async def test_login_no_allowed_adds_user(app):
auth = app.authenticator
mock_add_user = mock.Mock()
with mock.patch.object(auth, 'add_user', mock_add_user):

View File

@@ -0,0 +1,45 @@
"""tests for pagination"""
from pytest import mark
from pytest import raises
from traitlets.config import Config
from jupyterhub.pagination import Pagination
def test_per_page_bounds():
cfg = Config()
cfg.Pagination.max_per_page = 10
p = Pagination(config=cfg, per_page=20, total=100)
assert p.per_page == 10
with raises(Exception):
p.per_page = 0
@mark.parametrize(
"page, per_page, total, expected",
[
(1, 10, 99, [1, 2, 3, "...", 10]),
(2, 10, 99, [1, 2, 3, 4, "...", 10]),
(3, 10, 99, [1, 2, 3, 4, 5, "...", 10]),
(4, 10, 99, [1, 2, 3, 4, 5, 6, "...", 10]),
(5, 10, 99, [1, "...", 3, 4, 5, 6, 7, "...", 10]),
(6, 10, 99, [1, "...", 4, 5, 6, 7, 8, "...", 10]),
(7, 10, 99, [1, "...", 5, 6, 7, 8, 9, 10]),
(8, 10, 99, [1, "...", 6, 7, 8, 9, 10]),
(9, 10, 99, [1, "...", 7, 8, 9, 10]),
(1, 20, 99, [1, 2, 3, 4, 5]),
(1, 10, 0, [1]),
(1, 10, 1, [1]),
(1, 10, 10, [1]),
(1, 10, 11, [1, 2]),
(1, 10, 50, [1, 2, 3, 4, 5]),
(1, 10, 60, [1, 2, 3, 4, 5, 6]),
(1, 10, 70, [1, 2, 3, 4, 5, 6, 7]),
(1, 10, 80, [1, 2, 3, "...", 8]),
],
)
def test_window(page, per_page, total, expected):
cfg = Config()
cfg.Pagination
pagination = Pagination(page=page, per_page=per_page, total=total)
assert pagination.calculate_pages_window() == expected

View File

@@ -2,7 +2,6 @@
import json
import os
from contextlib import contextmanager
from queue import Queue
from subprocess import Popen
from urllib.parse import quote
from urllib.parse import urlparse

View File

@@ -2,18 +2,13 @@
import asyncio
import os
import sys
import time
from binascii import hexlify
from contextlib import contextmanager
from subprocess import Popen
from threading import Event
import pytest
import requests
from async_generator import async_generator
from async_generator import asynccontextmanager
from async_generator import yield_
from tornado import gen
from tornado.ioloop import IOLoop
from ..utils import maybe_future

View File

@@ -11,7 +11,6 @@ from threading import Thread
from unittest import mock
from urllib.parse import urlparse
import pytest
import requests
import requests_mock
from pytest import raises
@@ -185,7 +184,7 @@ def test_hub_authenticated(request):
m.get(good_url, text=json.dumps(mock_model))
# no whitelist
# no specific allowed user
r = requests.get(
'http://127.0.0.1:%i' % port,
cookies={'jubal': 'early'},
@@ -194,7 +193,7 @@ def test_hub_authenticated(request):
r.raise_for_status()
assert r.status_code == 200
# pass whitelist
# pass allowed user
TestHandler.hub_users = {'jubalearly'}
r = requests.get(
'http://127.0.0.1:%i' % port,
@@ -204,7 +203,7 @@ def test_hub_authenticated(request):
r.raise_for_status()
assert r.status_code == 200
# no pass whitelist
# no pass allowed ser
TestHandler.hub_users = {'kaylee'}
r = requests.get(
'http://127.0.0.1:%i' % port,
@@ -213,7 +212,7 @@ def test_hub_authenticated(request):
)
assert r.status_code == 403
# pass group whitelist
# pass allowed group
TestHandler.hub_groups = {'lions'}
r = requests.get(
'http://127.0.0.1:%i' % port,
@@ -223,7 +222,7 @@ def test_hub_authenticated(request):
r.raise_for_status()
assert r.status_code == 200
# no pass group whitelist
# no pass allowed group
TestHandler.hub_groups = {'tigers'}
r = requests.get(
'http://127.0.0.1:%i' % port,

Some files were not shown because too many files have changed in this diff Show More