Compare commits

...

42 Commits

Author SHA1 Message Date
Min RK
4692d6638d 0.8.0b4 2017-08-31 16:47:12 +02:00
Carol Willing
7829070e1c Merge pull request #1383 from minrk/singleuser-token-cookie
set cookie on singleuser when authenticated with ?token=...
2017-08-31 09:31:35 -05:00
Min RK
5e4b935322 only HubOAuth can set token cookie 2017-08-31 16:04:54 +02:00
Carol Willing
4c445c7a88 Add jencabral to contributors 2017-08-31 07:52:08 -05:00
Carol Willing
8e2965df6a Merge pull request #1384 from minrk/spawner-db
restore db access on Spawner
2017-08-31 07:50:18 -05:00
Min RK
7a41d24606 set cookie on singleuser when authenticated with ?token=...
Allows `/user/name?token=...` URL to login users for more than one request.

matches token behavior of regular notebook server.
2017-08-31 13:53:48 +02:00
Min RK
5f84a006dc restore db access on Spawner
Shouldn’t be strictly necessary, but doesn’t hurt
2017-08-31 10:03:44 +02:00
Carol Willing
e19296a230 Merge pull request #1382 from minrk/request-token
let admins request tokens for other users
2017-08-31 00:04:59 -04:00
Min RK
89ba97f413 exercise more token API cases
separate parametrize cases for clarity
2017-08-30 14:38:00 +02:00
Min RK
fe2157130b Merge pull request #1381 from minrk/log-fix
fix logging error when login_user is called with no form data and login fails
2017-08-30 14:09:52 +02:00
Min RK
e3b17e8176 Merge pull request #1379 from ding-c3/master
Pass timeout value to exponential_backoff in wait functions
2017-08-30 14:05:42 +02:00
Min RK
027f2f95c6 let admins request tokens for other users 2017-08-30 12:31:41 +02:00
Min RK
210975324a fix logging error when login_user is called with no form data and login fails 2017-08-30 11:31:44 +02:00
Alex Ding
932689f2f8 Pass timeout value to exponential_backoff in wait functions 2017-08-29 17:45:21 -07:00
Min RK
f91e911d1a Merge pull request #1375 from lsst-sqre/master
Prevent "extra" from being used before definition.
2017-08-29 08:36:25 -04:00
Adam Thornton
b75cce857e Merge pull request #1 from lsst-sqre/ticket/DM-11663
Fix "extra" so it isn't used before definition.
2017-08-28 19:00:17 -04:00
adam
62f00690f7 Fix "extra" so it isn't used before definition. 2017-08-28 15:58:31 -07:00
Yuvi Panda
f700ba4154 Merge pull request #1368 from minrk/check-version-error
Provide more detailed error message in case of version mismatch
2017-08-28 13:27:00 -04:00
Min RK
8b91842eae Merge pull request #1369 from minrk/template-typo
typo in navbar template
2017-08-27 16:41:44 -04:00
Min RK
80a9eb93f4 Merge pull request #1370 from yuvipanda/button-roles
Add role=button attribute to all <a> & <span> buttons
2017-08-27 15:39:04 -04:00
yuvipanda
e1deecbbfb Add role=button attribute to all <a> & <span> buttons
Simple accessibility win - screen readers will now be
able to properly present these as buttons than links.
2017-08-27 11:17:22 -04:00
Min RK
d3142704b7 typo in navbar template
mixed up elements causing funky alignment on some pages
2017-08-26 22:42:17 -04:00
Min RK
447edd081a Provide more detailed error message in case of version mismatch
this is the most likely cause of redirect loops when using docker,
so record the spawner version and check it when a redirect is detected.

In the event of a redirect and mismatch, fail with a message explaining the version mismatch and how to fix it.
2017-08-26 22:41:24 -04:00
Min RK
e1531ec277 Merge pull request #1366 from minrk/typo
typo in proxy recovery
2017-08-26 20:21:51 -04:00
Min RK
d12ac4b1f6 typo in proxy recovery
should have been the dict of instantiated services, not the list of service configurations
2017-08-26 15:25:17 -04:00
Min RK
17851b7586 0.8.0b3 2017-08-26 13:51:12 -04:00
Min RK
118e2fa610 Merge pull request #1364 from minrk/test-start-stop-race
exercise start/stop race conditions
2017-08-26 13:37:41 -04:00
Min RK
8e3553462c exercise start/stop race conditions
this doesn’t cover all the edge cases of each possible stage for the races, but it gets the basics covered.
2017-08-26 11:57:05 -04:00
Carol Willing
37da47d811 Merge pull request #1356 from minrk/proxy-race
rework spawn futures to fix races
2017-08-26 11:07:55 -04:00
Min RK
a640a468fb Merge pull request #1362 from stuartcampbell/master
Improve help comments for SSL key/certs configuration parameters
2017-08-26 09:41:47 -04:00
Min RK
92f034766e Merge pull request #1355 from minrk/update-oauth-secret
update oauth secret if API tokens change
2017-08-26 09:41:14 -04:00
Min RK
f7ea451df8 get the tests running 2017-08-25 18:12:15 -04:00
Stuart Campbell
1b7f54b462 Make SSL cert/key help clearer. 2017-08-25 14:52:23 -04:00
Stuart Campbell
b14b12231a Correct typo to have consistent comments 2017-08-24 16:53:25 -04:00
Min RK
2866be9462 don’t allow start while stop is pending
- start fails with 400 if stop is pending
- set spawn_pending across a whole spawn (including proxy)
- proxy_pending is only around the proxy
2017-08-23 23:35:19 -04:00
Min RK
f8648644bf ensure _stop_pending is always True on stop_single_user
previously there was a race during `delete_route`

apply the same logic as _start_pending
2017-08-23 18:30:49 -04:00
Min RK
69d4d48db0 rework spawn futures to fix races
1. set _proxy_pending before first wait to ensure that there is never a gap between setting spawn flags
2. always call `finish_user_spawn` to reduce the number of finalization cases
3. wait for proxy to finish on the slow_spawn timeout, not just start, because we are only interested in the total duration for page responsiveness
2017-08-21 11:27:30 +02:00
Min RK
df309749f2 update oauth secret if API tokens change
handle will_resume case correctly, where an API token *may* be re-used.

Previously, we only did it right if the token was *always* reused,
but clearing out a container would get it into a bad state.
2017-08-21 11:23:17 +02:00
Min RK
58751067db Merge pull request #1354 from minrk/log-typo
typo: use app_log, not self.log
2017-08-20 15:49:56 +02:00
Min RK
4fd70cf79b app_log typo 2017-08-20 15:48:55 +02:00
Carol Willing
ff15bad375 Merge pull request #1353 from minrk/log-connection-error
log error when failing to connect to Hub
2017-08-20 10:45:32 +02:00
Min RK
cba5bb1676 log error when failing to connect to Hub
for better diagnosis
2017-08-20 10:03:52 +02:00
23 changed files with 408 additions and 159 deletions

View File

@@ -31,6 +31,7 @@ contribution on JupyterHub:
- JamiesHQ - JamiesHQ
- jbweston - jbweston
- jdavidheiser - jdavidheiser
- jencabral
- jhamrick - jhamrick
- josephtate - josephtate
- kinuax - kinuax

View File

@@ -7,7 +7,7 @@ version_info = (
0, 0,
8, 8,
0, 0,
'b2', 'b4',
) )
__version__ = '.'.join(map(str, version_info)) __version__ = '.'.join(map(str, version_info))
@@ -28,6 +28,7 @@ def _check_version(hub_version, singleuser_version, log):
from distutils.version import LooseVersion as V from distutils.version import LooseVersion as V
hub_major_minor = V(hub_version).version[:2] hub_major_minor = V(hub_version).version[:2]
singleuser_major_minor = V(singleuser_version).version[:2] singleuser_major_minor = V(singleuser_version).version[:2]
extra = ""
if singleuser_major_minor == hub_major_minor: if singleuser_major_minor == hub_major_minor:
# patch-level mismatch or lower, log difference at debug-level # patch-level mismatch or lower, log difference at debug-level
# because this should be fine # because this should be fine
@@ -35,8 +36,11 @@ def _check_version(hub_version, singleuser_version, log):
else: else:
# log warning-level for more significant mismatch, such as 0.8 vs 0.9, etc. # log warning-level for more significant mismatch, such as 0.8 vs 0.9, etc.
log_method = log.warning log_method = log.warning
log_method("jupyterhub version %s != jupyterhub-singleuser version %s", extra = " This could cause failure to authenticate and result in redirect loops!"
hub_version, singleuser_version, log_method(
"jupyterhub version %s != jupyterhub-singleuser version %s." + extra,
hub_version,
singleuser_version,
) )
else: else:
log.debug("jupyterhub and jupyterhub-singleuser both on version %s" % hub_version) log.debug("jupyterhub and jupyterhub-singleuser both on version %s" % hub_version)

View File

@@ -41,15 +41,27 @@ class TokenAPIHandler(APIHandler):
# for authenticators where that's possible # for authenticators where that's possible
data = self.get_json_body() data = self.get_json_body()
try: try:
authenticated = yield self.authenticate(self, data) user = yield self.login_user(data)
except Exception as e: except Exception as e:
self.log.error("Failure trying to authenticate with form data: %s" % e) self.log.error("Failure trying to authenticate with form data: %s" % e)
authenticated = None user = None
if authenticated is None: if user is None:
raise web.HTTPError(403) raise web.HTTPError(403)
user = self.find_user(authenticated['name']) else:
data = self.get_json_body()
# admin users can request
if data and data.get('username') != user.name:
if user.admin:
user = self.find_user(data['username'])
if user is None:
raise web.HTTPError(400, "No such user '%s'" % data['username'])
else:
raise web.HTTPError(403, "Only admins can request tokens for other users.")
api_token = user.new_api_token() api_token = user.new_api_token()
self.write(json.dumps({'token': api_token})) self.write(json.dumps({
'token': api_token,
'user': self.user_model(user),
}))
class CookieAPIHandler(APIHandler): class CookieAPIHandler(APIHandler):

View File

@@ -104,22 +104,17 @@ class APIHandler(BaseHandler):
'pending': None, 'pending': None,
'last_activity': user.last_activity.isoformat(), 'last_activity': user.last_activity.isoformat(),
} }
if user.spawners['']._spawn_pending: model['pending'] = user.spawners[''].pending or None
model['pending'] = 'spawn'
elif user.spawners['']._stop_pending:
model['pending'] = 'stop'
if self.allow_named_servers: if self.allow_named_servers:
servers = model['servers'] = {} servers = model['servers'] = {}
for name, spawner in user.spawners.items(): for name, spawner in user.spawners.items():
if spawner.ready: if spawner.ready:
servers[name] = s = {'name': name} servers[name] = s = {'name': name}
if spawner._spawn_pending: if spawner.pending:
s['pending'] = 'spawn' s['pending'] = spawner.pending
elif spawner._stop_pending:
s['pending'] = 'stop'
if spawner.server: if spawner.server:
s['url'] = user.url + name s['url'] = user.url + name + '/'
return model return model
def group_model(self, group): def group_model(self, group):

View File

@@ -178,19 +178,34 @@ class UserAPIHandler(APIHandler):
class UserServerAPIHandler(APIHandler): class UserServerAPIHandler(APIHandler):
"""Start and stop single-user servers""" """Start and stop single-user servers"""
@gen.coroutine @gen.coroutine
@admin_or_self @admin_or_self
def post(self, name, server_name=''): def post(self, name, server_name=''):
user = self.find_user(name) user = self.find_user(name)
if server_name: if server_name and not self.allow_named_servers:
if not self.allow_named_servers:
raise web.HTTPError(400, "Named servers are not enabled.") raise web.HTTPError(400, "Named servers are not enabled.")
if self.allow_named_servers and not server_name:
server_name = user.default_server_name()
spawner = user.spawners[server_name] spawner = user.spawners[server_name]
pending = spawner.pending
if pending == 'spawn':
self.set_header('Content-Type', 'text/plain')
self.set_status(202)
return
elif pending:
raise web.HTTPError(400, "%s is pending %s" % (spawner._log_name, pending))
if spawner.ready: if spawner.ready:
# include notify, so that a server that died is noticed immediately # include notify, so that a server that died is noticed immediately
# set _spawn_pending flag to prevent races while we wait
spawner._spawn_pending = True
try:
state = yield spawner.poll_and_notify() state = yield spawner.poll_and_notify()
finally:
spawner._spawn_pending = False
if state is None: if state is None:
raise web.HTTPError(400, "%s's server %s is already running" % (name, server_name)) raise web.HTTPError(400, "%s is already running" % spawner._log_name)
options = self.get_json_body() options = self.get_json_body()
yield self.spawn_single_user(user, server_name, options=options) yield self.spawn_single_user(user, server_name, options=options)
@@ -209,17 +224,21 @@ class UserServerAPIHandler(APIHandler):
raise web.HTTPError(404, "%s has no server named '%s'" % (name, server_name)) raise web.HTTPError(404, "%s has no server named '%s'" % (name, server_name))
spawner = user.spawners[server_name] spawner = user.spawners[server_name]
if spawner.pending == 'stop':
if spawner._stop_pending: self.log.debug("%s already stopping", spawner._log_name)
self.set_header('Content-Type', 'text/plain') self.set_header('Content-Type', 'text/plain')
self.set_status(202) self.set_status(202)
return return
if not spawner.ready: if not spawner.ready:
raise web.HTTPError(400, "%s's server %s is not running" % (name, server_name)) raise web.HTTPError(
400, "%s is not running %s" %
(spawner._log_name, '(pending: %s)' % spawner.pending if spawner.pending else '')
)
# include notify, so that a server that died is noticed immediately # include notify, so that a server that died is noticed immediately
status = yield spawner.poll_and_notify() status = yield spawner.poll_and_notify()
if status is not None: if status is not None:
raise web.HTTPError(400, "%s's server %s is not running" % (name, server_name)) raise web.HTTPError(400, "%s is not running" % spawner._log_name)
yield self.stop_single_user(user, server_name) yield self.stop_single_user(user, server_name)
status = 202 if spawner._stop_pending else 204 status = 202 if spawner._stop_pending else 204
self.set_header('Content-Type', 'text/plain') self.set_header('Content-Type', 'text/plain')

View File

@@ -291,13 +291,13 @@ class JupyterHub(Application):
ssl_key = Unicode('', ssl_key = Unicode('',
help="""Path to SSL key file for the public facing interface of the proxy help="""Path to SSL key file for the public facing interface of the proxy
Use with ssl_cert When setting this, you should also set ssl_cert
""" """
).tag(config=True) ).tag(config=True)
ssl_cert = Unicode('', ssl_cert = Unicode('',
help="""Path to SSL certificate file for the public facing interface of the proxy help="""Path to SSL certificate file for the public facing interface of the proxy
Use with ssl_key When setting this, you should also set ssl_key
""" """
).tag(config=True) ).tag(config=True)
ip = Unicode('', ip = Unicode('',

View File

@@ -20,7 +20,7 @@ from .. import __version__
from .. import orm from .. import orm
from ..objects import Server from ..objects import Server
from ..spawner import LocalProcessSpawner from ..spawner import LocalProcessSpawner
from ..utils import url_path_join, exponential_backoff from ..utils import default_server_name, url_path_join
# pattern for the authentication token header # pattern for the authentication token header
auth_header_pat = re.compile(r'^(?:token|bearer)\s+([^\s]+)$', flags=re.IGNORECASE) auth_header_pat = re.compile(r'^(?:token|bearer)\s+([^\s]+)$', flags=re.IGNORECASE)
@@ -347,7 +347,7 @@ class BaseHandler(RequestHandler):
else: else:
self.statsd.incr('login.failure') self.statsd.incr('login.failure')
self.statsd.timing('login.authenticate.failure', auth_timer.ms) self.statsd.timing('login.authenticate.failure', auth_timer.ms)
self.log.warning("Failed login for %s", data.get('username', 'unknown user')) self.log.warning("Failed login for %s", (data or {}).get('username', 'unknown user'))
#--------------------------------------------------------------- #---------------------------------------------------------------
@@ -376,8 +376,16 @@ class BaseHandler(RequestHandler):
@gen.coroutine @gen.coroutine
def spawn_single_user(self, user, server_name='', options=None): def spawn_single_user(self, user, server_name='', options=None):
if server_name in user.spawners and user.spawners[server_name].pending == 'spawn': user_server_name = user.name
raise RuntimeError("Spawn already pending for: %s" % user.name) if self.allow_named_servers and not server_name:
server_name = default_server_name(user)
if server_name:
user_server_name = '%s:%s' % (user.name, server_name)
if server_name in user.spawners and user.spawners[server_name].pending:
pending = user.spawners[server_name].pending
raise RuntimeError("%s pending %s" % (user_server_name, pending))
# count active servers and pending spawns # count active servers and pending spawns
# we could do careful bookkeeping to avoid # we could do careful bookkeeping to avoid
@@ -397,26 +405,20 @@ class BaseHandler(RequestHandler):
) )
raise web.HTTPError( raise web.HTTPError(
429, 429,
"User startup rate limit exceeded. Try again in a few minutes.") "User startup rate limit exceeded. Try again in a few minutes.",
)
if active_server_limit and active_count >= active_server_limit: if active_server_limit and active_count >= active_server_limit:
self.log.info( self.log.info(
'%s servers active, no space available', '%s servers active, no space available',
active_count, active_count,
) )
raise web.HTTPError( raise web.HTTPError(429, "Active user limit exceeded. Try again in a few minutes.")
429,
"Active user limit exceeded. Try again in a few minutes.")
tic = IOLoop.current().time() tic = IOLoop.current().time()
user_server_name = user.name
if server_name:
user_server_name = '%s:%s' % (user.name, server_name)
else:
user_server_name = user.name
self.log.debug("Initiating spawn for %s", user_server_name) self.log.debug("Initiating spawn for %s", user_server_name)
f = user.spawn(server_name, options) spawn_future = user.spawn(server_name, options)
self.log.debug("%i%s concurrent spawns", self.log.debug("%i%s concurrent spawns",
spawn_pending_count, spawn_pending_count,
@@ -426,22 +428,28 @@ class BaseHandler(RequestHandler):
'/%i' % active_server_limit if active_server_limit else '') '/%i' % active_server_limit if active_server_limit else '')
spawner = user.spawners[server_name] spawner = user.spawners[server_name]
# set spawn_pending now, so there's no gap where _spawn_pending is False
# while we are waiting for _proxy_pending to be set
spawner._spawn_pending = True
@gen.coroutine @gen.coroutine
def finish_user_spawn(f=None): def finish_user_spawn():
"""Finish the user spawn by registering listeners and notifying the proxy. """Finish the user spawn by registering listeners and notifying the proxy.
If the spawner is slow to start, this is passed as an async callback, If the spawner is slow to start, this is passed as an async callback,
otherwise it is called immediately. otherwise it is called immediately.
""" """
if f and f.exception() is not None: # wait for spawn Future
# failed, don't add to the proxy try:
return yield spawn_future
except Exception:
spawner._spawn_pending = False
raise
toc = IOLoop.current().time() toc = IOLoop.current().time()
self.log.info("User %s took %.3f seconds to start", user_server_name, toc-tic) self.log.info("User %s took %.3f seconds to start", user_server_name, toc-tic)
self.statsd.timing('spawner.success', (toc - tic) * 1000) self.statsd.timing('spawner.success', (toc - tic) * 1000)
try:
spawner._proxy_pending = True spawner._proxy_pending = True
try:
yield self.proxy.add_user(user, server_name) yield self.proxy.add_user(user, server_name)
except Exception: except Exception:
self.log.exception("Failed to add %s to proxy!", user_server_name) self.log.exception("Failed to add %s to proxy!", user_server_name)
@@ -451,37 +459,41 @@ class BaseHandler(RequestHandler):
spawner.add_poll_callback(self.user_stopped, user, server_name) spawner.add_poll_callback(self.user_stopped, user, server_name)
finally: finally:
spawner._proxy_pending = False spawner._proxy_pending = False
spawner._spawn_pending = False
try: try:
yield gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), f) yield gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), finish_user_spawn())
except gen.TimeoutError: except gen.TimeoutError:
# waiting_for_response indicates server process has started, # waiting_for_response indicates server process has started,
# but is yet to become responsive. # but is yet to become responsive.
if not spawner._waiting_for_response: if spawner._spawn_pending and not spawner._waiting_for_response:
# still in Spawner.start, which is taking a long time # still in Spawner.start, which is taking a long time
# we shouldn't poll while spawn is incomplete. # we shouldn't poll while spawn is incomplete.
self.log.warning("User %s is slow to start (timeout=%s)", self.log.warning("User %s is slow to start (timeout=%s)",
user_server_name, self.slow_spawn_timeout) user_server_name, self.slow_spawn_timeout)
# schedule finish for when the user finishes spawning return
IOLoop.current().add_future(f, finish_user_spawn)
else:
# start has finished, but the server hasn't come up # start has finished, but the server hasn't come up
# check if the server died while we were waiting # check if the server died while we were waiting
status = yield user.spawner.poll() status = yield spawner.poll()
if status is None: if status is not None:
# hit timeout, but server's running. Hope that it'll show up soon enough,
# though it's possible that it started at the wrong URL
self.log.warning("User %s is slow to become responsive (timeout=%s)",
user_server_name, self.slow_spawn_timeout)
self.log.debug("Expecting server for %s at: %s", user_server_name, spawner.server.url)
# schedule finish for when the user finishes spawning
IOLoop.current().add_future(f, finish_user_spawn)
else:
toc = IOLoop.current().time() toc = IOLoop.current().time()
self.statsd.timing('spawner.failure', (toc - tic) * 1000) self.statsd.timing('spawner.failure', (toc - tic) * 1000)
raise web.HTTPError(500, "Spawner failed to start [status=%s]" % status) raise web.HTTPError(500, "Spawner failed to start [status=%s]" % status)
else:
yield finish_user_spawn() if spawner._waiting_for_response:
# hit timeout waiting for response, but server's running.
# Hope that it'll show up soon enough,
# though it's possible that it started at the wrong URL
self.log.warning("User %s is slow to become responsive (timeout=%s)",
user_server_name, self.slow_spawn_timeout)
self.log.debug("Expecting server for %s at: %s",
user_server_name, spawner.server.url)
if spawner._proxy_pending:
# User.spawn finished, but it hasn't been added to the proxy
# Could be due to load or a slow proxy
self.log.warning("User %s is slow to be added to the proxy (timeout=%s)",
user_server_name, self.slow_spawn_timeout)
@gen.coroutine @gen.coroutine
def user_stopped(self, user, server_name): def user_stopped(self, user, server_name):
@@ -501,36 +513,37 @@ class BaseHandler(RequestHandler):
if name not in user.spawners: if name not in user.spawners:
raise KeyError("User %s has no such spawner %r", user.name, name) raise KeyError("User %s has no such spawner %r", user.name, name)
spawner = user.spawners[name] spawner = user.spawners[name]
if spawner._stop_pending: if spawner.pending:
raise RuntimeError("Stop already pending for: %s:%s" % (user.name, name)) raise RuntimeError("%s pending %s" % (spawner._log_name, spawner.pending))
tic = IOLoop.current().time() # set user._stop_pending before doing anything async
yield self.proxy.delete_user(user, name) # to avoid races
f = user.stop() spawner._stop_pending = True
@gen.coroutine
def finish_stop(f=None):
"""Finish the stop action by noticing that the user is stopped.
If the spawner is slow to stop, this is passed as an async callback, @gen.coroutine
otherwise it is called immediately. def stop():
"""Stop the server
1. remove it from the proxy
2. stop the server
3. notice that it stopped
""" """
if f and f.exception() is not None: tic = IOLoop.current().time()
# failed, don't do anything try:
return yield self.proxy.delete_user(user, name)
yield user.stop(name)
finally:
spawner._stop_pending = False
toc = IOLoop.current().time() toc = IOLoop.current().time()
self.log.info("User %s server took %.3f seconds to stop", user.name, toc - tic) self.log.info("User %s server took %.3f seconds to stop", user.name, toc - tic)
try: try:
yield gen.with_timeout(timedelta(seconds=self.slow_stop_timeout), f) yield gen.with_timeout(timedelta(seconds=self.slow_stop_timeout), stop())
except gen.TimeoutError: except gen.TimeoutError:
if spawner._stop_pending: if spawner._stop_pending:
# hit timeout, but stop is still pending # hit timeout, but stop is still pending
self.log.warning("User %s:%s server is slow to stop", user.name, name) self.log.warning("User %s:%s server is slow to stop", user.name, name)
# schedule finish for when the server finishes stopping
IOLoop.current().add_future(f, finish_stop)
else: else:
raise raise
else:
yield finish_stop()
#--------------------------------------------------------------- #---------------------------------------------------------------
# template rendering # template rendering
@@ -653,7 +666,8 @@ class UserSpawnHandler(BaseHandler):
# logged in as correct user, spawn the server # logged in as correct user, spawn the server
spawner = current_user.spawner spawner = current_user.spawner
if spawner._spawn_pending or spawner._proxy_pending: if spawner.pending:
self.log.info("%s is pending %s", spawner._log_name, spawner.pending)
# spawn has started, but not finished # spawn has started, but not finished
self.statsd.incr('redirects.user_spawn_pending', 1) self.statsd.incr('redirects.user_spawn_pending', 1)
html = self.render_template("spawn_pending.html", user=current_user) html = self.render_template("spawn_pending.html", user=current_user)
@@ -661,7 +675,10 @@ class UserSpawnHandler(BaseHandler):
return return
# spawn has supposedly finished, check on the status # spawn has supposedly finished, check on the status
if spawner.ready:
status = yield spawner.poll() status = yield spawner.poll()
else:
status = 0
if status is not None: if status is not None:
if spawner.options_form: if spawner.options_form:
self.redirect(url_concat(url_path_join(self.hub.base_url, 'spawn'), self.redirect(url_concat(url_path_join(self.hub.base_url, 'spawn'),
@@ -679,9 +696,23 @@ class UserSpawnHandler(BaseHandler):
self.log.warning("Invalid redirects argument %r", self.get_argument('redirects')) self.log.warning("Invalid redirects argument %r", self.get_argument('redirects'))
redirects = 0 redirects = 0
if redirects >= self.settings.get('user_redirect_limit', 5): # check redirect limit to prevent browser-enforced limits.
# In case of version mismatch, raise on only two redirects.
if redirects >= self.settings.get(
'user_redirect_limit', 4
) or (redirects >= 2 and spawner._jupyterhub_version != __version__):
# We stop if we've been redirected too many times. # We stop if we've been redirected too many times.
raise web.HTTPError(500, "Redirect loop detected.") msg = "Redirect loop detected."
if spawner._jupyterhub_version != __version__:
msg += (
" Notebook has jupyterhub version {singleuser}, but the Hub expects {hub}."
" Try installing jupyterhub=={hub} in the user environment"
" if you continue to have problems."
).format(
singleuser=spawner._jupyterhub_version or 'unknown (likely < 0.8)',
hub=__version__,
)
raise web.HTTPError(500, msg)
# set login cookie anew # set login cookie anew
self.set_login_cookie(current_user) self.set_login_cookie(current_user)

View File

@@ -84,10 +84,11 @@ class LoginHandler(BaseHandler):
if user: if user:
already_running = False already_running = False
if user.spawner: if user.spawner.ready:
status = yield user.spawner.poll() status = yield user.spawner.poll()
already_running = (status is None) already_running = (status is None)
if not already_running and not user.spawner.options_form: if not already_running and not user.spawner.options_form \
and not user.spawner.pending:
# logging in triggers spawn # logging in triggers spawn
yield self.spawn_single_user(user) yield self.spawn_single_user(user)
self.redirect(self.get_next_url()) self.redirect(self.get_next_url())

View File

@@ -115,6 +115,10 @@ class SpawnHandler(BaseHandler):
self.log.warning("User is already running: %s", url) self.log.warning("User is already running: %s", url)
self.redirect(url) self.redirect(url)
return return
if user.spawner.pending:
raise web.HTTPError(
400, "%s is pending %s" % (user.spawner._log_name, user.spawner.pending)
)
form_options = {} form_options = {}
for key, byte_list in self.request.body_arguments.items(): for key, byte_list in self.request.body_arguments.items():
form_options[key] = [ bs.decode('utf8') for bs in byte_list ] form_options[key] = [ bs.decode('utf8') for bs in byte_list ]

View File

@@ -231,9 +231,10 @@ class Proxy(LoggingConfigurable):
user.name, spawner.proxy_spec, spawner.server.host, user.name, spawner.proxy_spec, spawner.server.host,
) )
if spawner._spawn_pending: if spawner.pending and spawner.pending != 'spawn':
raise RuntimeError( raise RuntimeError(
"User %s's spawn is pending, shouldn't be added to the proxy yet!", user.name) "%s is pending %s, shouldn't be added to the proxy yet!" % (spawner._log_name, spawner.pending)
)
yield self.add_route( yield self.add_route(
spawner.proxy_spec, spawner.proxy_spec,
@@ -326,7 +327,7 @@ class Proxy(LoggingConfigurable):
spec, route['target'], spawner.server, spec, route['target'], spawner.server,
) )
futures.append(self.add_user(user, name)) futures.append(self.add_user(user, name))
elif spawner._proxy_pending: elif spawner._spawn_pending:
good_routes.add(spawner.proxy_spec) good_routes.add(spawner.proxy_spec)
# check service routes # check service routes
@@ -374,7 +375,7 @@ class Proxy(LoggingConfigurable):
self.log.info("Setting up routes on new proxy") self.log.info("Setting up routes on new proxy")
yield self.add_hub_route(self.app.hub) yield self.add_hub_route(self.app.hub)
yield self.add_all_users(self.app.users) yield self.add_all_users(self.app.users)
yield self.add_all_services(self.app.services) yield self.add_all_services(self.app._service_map)
self.log.info("New proxy back up and good to go") self.log.info("New proxy back up and good to go")

View File

@@ -243,7 +243,8 @@ class HubAuth(Configurable):
headers.setdefault('Authorization', 'token %s' % self.api_token) headers.setdefault('Authorization', 'token %s' % self.api_token)
try: try:
r = requests.request(method, url, **kwargs) r = requests.request(method, url, **kwargs)
except requests.ConnectionError: except requests.ConnectionError as e:
app_log.error("Error connecting to %s: %s", self.api_url, e)
msg = "Failed to connect to Hub API at %r." % self.api_url msg = "Failed to connect to Hub API at %r." % self.api_url
msg += " Is the Hub accessible at this URL (from host: %s)?" % socket.gethostname() msg += " Is the Hub accessible at this URL (from host: %s)?" % socket.gethostname()
if '127.0.0.1' in self.api_url: if '127.0.0.1' in self.api_url:
@@ -730,6 +731,19 @@ class HubAuthenticated(object):
except Exception: except Exception:
self._hub_auth_user_cache = None self._hub_auth_user_cache = None
raise raise
# store ?token=... tokens passed via url in a cookie for future requests
url_token = self.get_argument('token', '')
if (
user_model
and url_token
and getattr(self, '_token_authenticated', False)
and hasattr(self.hub_auth, 'set_cookie')
):
# authenticated via `?token=`
# set a cookie for future requests
# hub_auth.set_cookie is only available on HubOAuth
self.hub_auth.set_cookie(self, url_token)
return self._hub_auth_user_cache return self._hub_auth_user_cache

View File

@@ -18,7 +18,7 @@ from tempfile import mkdtemp
from sqlalchemy import inspect from sqlalchemy import inspect
from tornado import gen from tornado import gen
from tornado.ioloop import PeriodicCallback, IOLoop from tornado.ioloop import PeriodicCallback
from traitlets.config import LoggingConfigurable from traitlets.config import LoggingConfigurable
from traitlets import ( from traitlets import (
@@ -49,9 +49,22 @@ class Spawner(LoggingConfigurable):
# private attributes for tracking status # private attributes for tracking status
_spawn_pending = False _spawn_pending = False
_start_pending = False
_stop_pending = False _stop_pending = False
_proxy_pending = False _proxy_pending = False
_waiting_for_response = False _waiting_for_response = False
_jupyterhub_version = None
@property
def _log_name(self):
"""Return username:servername or username
Used in logging for consistency with named servers.
"""
if self.name:
return '%s:%s' % (self.user.name, self.name)
else:
return self.user.name
@property @property
def pending(self): def pending(self):
@@ -59,7 +72,7 @@ class Spawner(LoggingConfigurable):
Return False if nothing is pending. Return False if nothing is pending.
""" """
if self._spawn_pending or self._proxy_pending: if self._spawn_pending:
return 'spawn' return 'spawn'
elif self._stop_pending: elif self._stop_pending:
return 'stop' return 'stop'
@@ -89,6 +102,7 @@ class Spawner(LoggingConfigurable):
authenticator = Any() authenticator = Any()
hub = Any() hub = Any()
orm_spawner = Any() orm_spawner = Any()
db = Any()
@observe('orm_spawner') @observe('orm_spawner')
def _orm_spawner_changed(self, change): def _orm_spawner_changed(self, change):

View File

@@ -89,7 +89,7 @@ def api_request(app, *api_path, **kwargs):
base_url = app.hub.url base_url = app.hub.url
headers = kwargs.setdefault('headers', {}) headers = kwargs.setdefault('headers', {})
if 'Authorization' not in headers: if 'Authorization' not in headers and not kwargs.pop('noauth', False):
headers.update(auth_header(app.db, 'admin')) headers.update(auth_header(app.db, 'admin'))
url = ujoin(base_url, 'api', *api_path) url = ujoin(base_url, 'api', *api_path)
@@ -654,6 +654,50 @@ def test_active_server_limit(app, request):
assert counts['pending'] == 0 assert counts['pending'] == 0
@mark.gen_test
def test_start_stop_race(app, no_patience, slow_spawn):
user = add_user(app.db, app, name='panda')
spawner = user.spawner
# start the server
r = yield api_request(app, 'users', user.name, 'server', method='post')
assert r.status_code == 202
assert spawner.pending == 'spawn'
# additional spawns while spawning shouldn't trigger a new spawn
with mock.patch.object(spawner, 'start') as m:
r = yield api_request(app, 'users', user.name, 'server', method='post')
assert r.status_code == 202
assert m.call_count == 0
# stop while spawning is not okay
r = yield api_request(app, 'users', user.name, 'server', method='delete')
assert r.status_code == 400
while not spawner.ready:
yield gen.sleep(0.1)
spawner.delay = 3
# stop the spawner
r = yield api_request(app, 'users', user.name, 'server', method='delete')
assert r.status_code == 202
assert spawner.pending == 'stop'
# make sure we get past deleting from the proxy
yield gen.sleep(1)
# additional stops while stopping shouldn't trigger a new stop
with mock.patch.object(spawner, 'stop') as m:
r = yield api_request(app, 'users', user.name, 'server', method='delete')
assert r.status_code == 202
assert m.call_count == 0
# start while stopping is not allowed
with mock.patch.object(spawner, 'start') as m:
r = yield api_request(app, 'users', user.name, 'server', method='post')
assert r.status_code == 400
while spawner.active:
yield gen.sleep(0.1)
# start after stop is okay
r = yield api_request(app, 'users', user.name, 'server', method='post')
assert r.status_code == 202
@mark.gen_test @mark.gen_test
def test_get_proxy(app): def test_get_proxy(app):
r = yield api_request(app, 'proxy') r = yield api_request(app, 'proxy')
@@ -711,16 +755,16 @@ def test_token(app):
@mark.gen_test @mark.gen_test
@mark.parametrize("headers, data, status", [ @mark.parametrize("headers, status", [
({}, None, 200), ({}, 200),
({'Authorization': ''}, None, 403), ({'Authorization': 'token bad'}, 403),
({}, {'username': 'fake', 'password': 'fake'}, 200),
]) ])
def test_get_new_token(app, headers, data, status): def test_get_new_token(app, headers, status):
if data:
data = json.dumps(data)
# request a new token # request a new token
r = yield api_request(app, 'authorizations', 'token', method='post', data=data, headers=headers) r = yield api_request(app, 'authorizations', 'token',
method='post',
headers=headers,
)
assert r.status_code == status assert r.status_code == status
if status != 200: if status != 200:
return return
@@ -728,7 +772,61 @@ def test_get_new_token(app, headers, data, status):
assert 'token' in reply assert 'token' in reply
r = yield api_request(app, 'authorizations', 'token', reply['token']) r = yield api_request(app, 'authorizations', 'token', reply['token'])
r.raise_for_status() r.raise_for_status()
assert 'name' in r.json() reply = r.json()
assert reply['name'] == 'admin'
@mark.gen_test
def test_token_formdata(app):
"""Create a token for a user with formdata and no auth header"""
data = {
'username': 'fake',
'password': 'fake',
}
r = yield api_request(app, 'authorizations', 'token',
method='post',
data=json.dumps(data) if data else None,
noauth=True,
)
assert r.status_code == 200
reply = r.json()
assert 'token' in reply
r = yield api_request(app, 'authorizations', 'token', reply['token'])
r.raise_for_status()
reply = r.json()
assert reply['name'] == data['username']
@mark.gen_test
@mark.parametrize("as_user, for_user, status", [
('admin', 'other', 200),
('admin', 'missing', 400),
('user', 'other', 403),
('user', 'user', 200),
])
def test_token_as_user(app, as_user, for_user, status):
# ensure both users exist
u = add_user(app.db, app, name=as_user)
if for_user != 'missing':
add_user(app.db, app, name=for_user)
data = {'username': for_user}
headers = {
'Authorization': 'token %s' % u.new_api_token(),
}
r = yield api_request(app, 'authorizations', 'token',
method='post',
data=json.dumps(data),
headers=headers,
)
assert r.status_code == status
reply = r.json()
if status != 200:
return
assert 'token' in reply
r = yield api_request(app, 'authorizations', 'token', reply['token'])
r.raise_for_status()
reply = r.json()
assert reply['name'] == data['username']
# --------------- # ---------------

View File

@@ -38,6 +38,27 @@ def test_create_named_server(app, named_servers):
assert prefix == user.spawners[servername].server.base_url assert prefix == user.spawners[servername].server.base_url
assert prefix.endswith('/user/%s/%s/' % (username, servername)) assert prefix.endswith('/user/%s/%s/' % (username, servername))
r = yield api_request(app, 'users', username)
r.raise_for_status()
user_model = r.json()
user_model.pop('last_activity')
assert user_model == {
'name': username,
'groups': [],
'kind': 'user',
'admin': False,
'pending': None,
'server': None,
'servers': {
name: {
'name': name,
'url': url_path_join(user.url, name, '/'),
}
for name in ['1', servername]
},
}
@pytest.mark.gen_test @pytest.mark.gen_test
def test_delete_named_server(app, named_servers): def test_delete_named_server(app, named_servers):
@@ -69,9 +90,9 @@ def test_delete_named_server(app, named_servers):
'servers': { 'servers': {
name: { name: {
'name': name, 'name': name,
'url': url_path_join(user.url, name), 'url': url_path_join(user.url, name, '/'),
} }
for name in ['1', servername] for name in ['1']
}, },
} }

View File

@@ -292,6 +292,7 @@ def test_hubauth_service_token(app, mockservice_url):
'name': name, 'name': name,
'admin': False, 'admin': False,
} }
assert not r.cookies
# token in ?token parameter # token in ?token parameter
r = yield async_requests.get(public_url(app, mockservice_url) + '/whoami/?token=%s' % token) r = yield async_requests.get(public_url(app, mockservice_url) + '/whoami/?token=%s' % token)
@@ -319,7 +320,8 @@ def test_oauth_service(app, mockservice_url):
# first request is only going to set login cookie # first request is only going to set login cookie
# FIXME: redirect to originating URL (OAuth loses this info) # FIXME: redirect to originating URL (OAuth loses this info)
s = requests.Session() s = requests.Session()
s.cookies = yield app.login_user('link') name = 'link'
s.cookies = yield app.login_user(name)
# run session.get in async_requests thread # run session.get in async_requests thread
s_get = lambda *args, **kwargs: async_requests.executor.submit(s.get, *args, **kwargs) s_get = lambda *args, **kwargs: async_requests.executor.submit(s.get, *args, **kwargs)
r = yield s_get(url) r = yield s_get(url)
@@ -335,3 +337,23 @@ def test_oauth_service(app, mockservice_url):
'kind': 'user', 'kind': 'user',
} }
# token-authenticated request to HubOAuth
token = app.users[name].new_api_token()
# token in ?token parameter
r = yield async_requests.get(public_url(app, mockservice_url) + 'owhoami/?token=%s' % token)
r.raise_for_status()
reply = r.json()
assert reply['name'] == name
# verify that ?token= requests set a cookie
assert len(r.cookies) != 0
# ensure cookie works in future requests
r = yield async_requests.get(
public_url(app, mockservice_url) + 'owhoami/',
cookies=r.cookies,
allow_redirects=False,
)
r.raise_for_status()
reply = r.json()
assert reply['name'] == name

View File

@@ -201,6 +201,7 @@ class User(HasTraits):
authenticator=self.authenticator, authenticator=self.authenticator,
config=self.settings.get('config'), config=self.settings.get('config'),
proxy_spec=url_path_join(self.proxy_spec, name, '/'), proxy_spec=url_path_join(self.proxy_spec, name, '/'),
db=self.db,
) )
# update with kwargs. Mainly for testing. # update with kwargs. Mainly for testing.
spawn_kwargs.update(kwargs) spawn_kwargs.update(kwargs)
@@ -317,8 +318,6 @@ class User(HasTraits):
url of the server will be /user/:name/:server_name url of the server will be /user/:name/:server_name
""" """
db = self.db db = self.db
if self.allow_named_servers and not server_name:
server_name = default_server_name(self)
base_url = url_path_join(self.base_url, server_name) + '/' base_url = url_path_join(self.base_url, server_name) + '/'
@@ -356,11 +355,10 @@ class User(HasTraits):
oauth_client = client_store.fetch_by_client_id(client_id) oauth_client = client_store.fetch_by_client_id(client_id)
except ClientNotFoundError: except ClientNotFoundError:
oauth_client = None oauth_client = None
# create a new OAuth client + secret on every launch, # create a new OAuth client + secret on every launch
# except for resuming containers. # containers that resume will be updated below
if oauth_client is None or not spawner.will_resume:
client_store.add_client(client_id, api_token, client_store.add_client(client_id, api_token,
url_path_join(self.url, 'oauth_callback'), url_path_join(self.url, server_name, 'oauth_callback'),
) )
db.commit() db.commit()
@@ -369,7 +367,7 @@ class User(HasTraits):
if (authenticator): if (authenticator):
yield gen.maybe_future(authenticator.pre_spawn_start(self, spawner)) yield gen.maybe_future(authenticator.pre_spawn_start(self, spawner))
spawner._spawn_pending = True spawner._start_pending = True
# wait for spawner.start to return # wait for spawner.start to return
try: try:
# run optional preparation work to bootstrap the notebook # run optional preparation work to bootstrap the notebook
@@ -409,6 +407,13 @@ class User(HasTraits):
# use generated=False because we don't trust this token # use generated=False because we don't trust this token
# to have been generated properly # to have been generated properly
self.new_api_token(spawner.api_token, generated=False) self.new_api_token(spawner.api_token, generated=False)
# update OAuth client secret with updated API token
if oauth_provider:
client_store = oauth_provider.client_authenticator.client_store
client_store.add_client(client_id, spawner.api_token,
url_path_join(self.url, server_name, 'oauth_callback'),
)
db.commit()
except Exception as e: except Exception as e:
if isinstance(e, gen.TimeoutError): if isinstance(e, gen.TimeoutError):
@@ -428,6 +433,7 @@ class User(HasTraits):
user=self.name, user=self.name,
), exc_info=True) ), exc_info=True)
# raise original exception # raise original exception
spawner._start_pending = False
raise e raise e
spawner.start_polling() spawner.start_polling()
@@ -467,9 +473,12 @@ class User(HasTraits):
else: else:
server_version = resp.headers.get('X-JupyterHub-Version') server_version = resp.headers.get('X-JupyterHub-Version')
_check_version(__version__, server_version, self.log) _check_version(__version__, server_version, self.log)
# record the Spawner version for better error messages
# if it doesn't work
spawner._jupyterhub_version = server_version
finally: finally:
spawner._waiting_for_response = False spawner._waiting_for_response = False
spawner._spawn_pending = False spawner._start_pending = False
return self return self
@gen.coroutine @gen.coroutine
@@ -480,6 +489,7 @@ class User(HasTraits):
""" """
spawner = self.spawners[server_name] spawner = self.spawners[server_name]
spawner._spawn_pending = False spawner._spawn_pending = False
spawner._start_pending = False
spawner.stop_polling() spawner.stop_polling()
spawner._stop_pending = True spawner._stop_pending = True
try: try:

View File

@@ -142,7 +142,8 @@ def wait_for_server(ip, port, timeout=10):
ip = '127.0.0.1' ip = '127.0.0.1'
yield exponential_backoff( yield exponential_backoff(
lambda: can_connect(ip, port), lambda: can_connect(ip, port),
"Server at {ip}:{port} didn't respond in {timeout} seconds".format(ip=ip, port=port, timeout=timeout) "Server at {ip}:{port} didn't respond in {timeout} seconds".format(ip=ip, port=port, timeout=timeout),
timeout=timeout
) )
@@ -175,7 +176,8 @@ def wait_for_http_server(url, timeout=10):
return False return False
re = yield exponential_backoff( re = yield exponential_backoff(
is_reachable, is_reachable,
"Server at {url} didn't respond in {timeout} seconds".format(url=url, timeout=timeout) "Server at {url} didn't respond in {timeout} seconds".format(url=url, timeout=timeout),
timeout=timeout
) )
return re return re

View File

@@ -32,9 +32,9 @@
<tbody> <tbody>
<tr class="user-row add-user-row"> <tr class="user-row add-user-row">
<td colspan="12"> <td colspan="12">
<a id="add-users" class="col-xs-2 btn btn-default">Add Users</a> <a id="add-users" role="button" class="col-xs-2 btn btn-default">Add Users</a>
<a id="stop-all-servers" class="col-xs-2 col-xs-offset-5 btn btn-danger">Stop All</a> <a id="stop-all-servers" role="button" class="col-xs-2 col-xs-offset-5 btn btn-danger">Stop All</a>
<a id="shutdown-hub" class="col-xs-2 col-xs-offset-1 btn btn-danger">Shutdown Hub</a> <a id="shutdown-hub" role="button" class="col-xs-2 col-xs-offset-1 btn btn-danger">Shutdown Hub</a>
</td> </td>
</tr> </tr>
{% for u in users %} {% for u in users %}
@@ -44,20 +44,20 @@
<td class="admin-col col-sm-2">{% if u.admin %}admin{% endif %}</td> <td class="admin-col col-sm-2">{% if u.admin %}admin{% endif %}</td>
<td class="time-col col-sm-3">{{u.last_activity.isoformat() + 'Z'}}</td> <td class="time-col col-sm-3">{{u.last_activity.isoformat() + 'Z'}}</td>
<td class="server-col col-sm-2 text-center"> <td class="server-col col-sm-2 text-center">
<span class="stop-server btn btn-xs btn-danger {% if not u.running %}hidden{% endif %}">stop server</span> <span role="button" class="stop-server btn btn-xs btn-danger {% if not u.running %}hidden{% endif %}">stop server</span>
<span class="start-server btn btn-xs btn-success {% if u.running %}hidden{% endif %}">start server</span> <span role="button" class="start-server btn btn-xs btn-success {% if u.running %}hidden{% endif %}">start server</span>
</td> </td>
<td class="server-col col-sm-1 text-center"> <td class="server-col col-sm-1 text-center">
{% if admin_access %} {% if admin_access %}
<span class="access-server btn btn-xs btn-success {% if not u.running %}hidden{% endif %}">access server</span> <span role="button" class="access-server btn btn-xs btn-success {% if not u.running %}hidden{% endif %}">access server</span>
{% endif %} {% endif %}
</td> </td>
<td class="edit-col col-sm-1 text-center"> <td class="edit-col col-sm-1 text-center">
<span class="edit-user btn btn-xs btn-primary">edit</span> <span role="button" class="edit-user btn btn-xs btn-primary">edit</span>
</td> </td>
<td class="edit-col col-sm-1 text-center"> <td class="edit-col col-sm-1 text-center">
{% if u.name != user.name %} {% if u.name != user.name %}
<span class="delete-user btn btn-xs btn-danger">delete</span> <span role="button" class="delete-user btn btn-xs btn-danger">delete</span>
{% endif %} {% endif %}
</td> </td>
{% endblock user_row %} {% endblock user_row %}

View File

@@ -6,9 +6,9 @@
<div class="row"> <div class="row">
<div class="text-center"> <div class="text-center">
{% if user.running %} {% if user.running %}
<a id="stop" class="btn btn-lg btn-danger">Stop My Server</a> <a id="stop" role="button" class="btn btn-lg btn-danger">Stop My Server</a>
{% endif %} {% endif %}
<a id="start" class="btn btn-lg btn-success" href="{{ url }}"> <a id="start"role="button" class="btn btn-lg btn-success" href="{{ url }}">
{% if not user.running %} {% if not user.running %}
Start Start
{% endif %} {% endif %}

View File

@@ -11,7 +11,7 @@
{{ custom_html }} {{ custom_html }}
{% elif login_service %} {% elif login_service %}
<div class="service-login"> <div class="service-login">
<a class='btn btn-jupyter btn-lg' href='{{authenticator_login_url}}'> <a role="button" class='btn btn-jupyter btn-lg' href='{{authenticator_login_url}}'>
Sign in with {{login_service}} Sign in with {{login_service}}
</a> </a>
</div> </div>

View File

@@ -99,9 +99,9 @@
<ul class="nav navbar-nav"> <ul class="nav navbar-nav">
<li><a href="{{base_url}}home">Home</a></li> <li><a href="{{base_url}}home">Home</a></li>
<li><a href="{{base_url}}token">Token</a></li> <li><a href="{{base_url}}token">Token</a></li>
{% endif %}
{% if user.admin %} {% if user.admin %}
<li><a href="{{base_url}}admin">Admin</a></li> <li><a href="{{base_url}}admin">Admin</a></li>
{% endif %}
</ul> </ul>
{% endif %} {% endif %}
<ul class="nav navbar-nav navbar-right"> <ul class="nav navbar-nav navbar-right">
@@ -109,9 +109,9 @@
{% block login_widget %} {% block login_widget %}
<span id="login_widget"> <span id="login_widget">
{% if user %} {% if user %}
<a id="logout" class="navbar-btn btn-sm btn btn-default" href="{{logout_url}}"> <i aria-hidden="true" class="fa fa-sign-out"></i> Logout</a> <a id="logout" role="button" class="navbar-btn btn-sm btn btn-default" href="{{logout_url}}"> <i aria-hidden="true" class="fa fa-sign-out"></i> Logout</a>
{% else %} {% else %}
<a id="login" class="btn-sm btn navbar-btn btn-default" href="{{login_url}}">Login</a> <a id="login" role="button" class="btn-sm btn navbar-btn btn-default" href="{{login_url}}">Login</a>
{% endif %} {% endif %}
</span> </span>
{% endblock %} {% endblock %}

View File

@@ -8,7 +8,7 @@
<p>Your server is starting up.</p> <p>Your server is starting up.</p>
<p>You will be redirected automatically when it's ready for you.</p> <p>You will be redirected automatically when it's ready for you.</p>
<p><i class="fa fa-spinner fa-pulse fa-fw fa-3x" aria-hidden="true"></i></p> <p><i class="fa fa-spinner fa-pulse fa-fw fa-3x" aria-hidden="true"></i></p>
<a id="refresh" class="btn btn-lg btn-primary" href="#">refresh</a> <a role="button" id="refresh" class="btn btn-lg btn-primary" href="#">refresh</a>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -5,7 +5,7 @@
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="text-center"> <div class="text-center">
<a id="request-token" class="btn btn-lg btn-jupyter" href="#"> <a id="request-token" role="button" class="btn btn-lg btn-jupyter" href="#">
Request new API token Request new API token
</a> </a>
</div> </div>