mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-15 05:53:00 +00:00
Compare commits
61 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
6bbfcdfe4f | ||
![]() |
25662285af | ||
![]() |
84d12e8d72 | ||
![]() |
c317cbce36 | ||
![]() |
d279604fac | ||
![]() |
70fc4ef886 | ||
![]() |
24ff91eef5 | ||
![]() |
afc6789c74 | ||
![]() |
819e5e222a | ||
![]() |
e1a4f37bbc | ||
![]() |
a73477feed | ||
![]() |
89722ee2f3 | ||
![]() |
30d4b2cef4 | ||
![]() |
ca4fce7ffb | ||
![]() |
018b2daace | ||
![]() |
fd01165cf6 | ||
![]() |
34e4719893 | ||
![]() |
c6ac9e1d15 | ||
![]() |
70b8876239 | ||
![]() |
5e34f4481a | ||
![]() |
eae5594698 | ||
![]() |
f02022a00c | ||
![]() |
f964013516 | ||
![]() |
5f7ffaf1f6 | ||
![]() |
0e7ccb7520 | ||
![]() |
c9db504a49 | ||
![]() |
716677393e | ||
![]() |
ba8484f161 | ||
![]() |
ceec84dbb4 | ||
![]() |
f2a83ec846 | ||
![]() |
7deea6083a | ||
![]() |
a169ff3548 | ||
![]() |
f84a88da21 | ||
![]() |
eecec7183e | ||
![]() |
f11705ee26 | ||
![]() |
78ac5abf23 | ||
![]() |
2beeaa0932 | ||
![]() |
90cb8423bc | ||
![]() |
3b07bd286b | ||
![]() |
73564b97ea | ||
![]() |
65cad5efad | ||
![]() |
52eb627cd6 | ||
![]() |
506e568a9a | ||
![]() |
6c89de082f | ||
![]() |
6fb31cc613 | ||
![]() |
cfb22baf05 | ||
![]() |
2d0c1ff0a8 | ||
![]() |
7789e13879 | ||
![]() |
f7b90e2c09 | ||
![]() |
ccb29167dd | ||
![]() |
4ef1eca3c9 | ||
![]() |
c26ede30b9 | ||
![]() |
64c69a3164 | ||
![]() |
ad7867ff11 | ||
![]() |
14fc1588f8 | ||
![]() |
7e5a925f4f | ||
![]() |
3c61e422da | ||
![]() |
0e2cf37981 | ||
![]() |
503d5e389f | ||
![]() |
7b1e61ab2c | ||
![]() |
f9a90d2494 |
@@ -1,5 +1,7 @@
|
||||
language: python
|
||||
sudo: false
|
||||
cache:
|
||||
- pip
|
||||
python:
|
||||
- nightly
|
||||
- 3.6
|
||||
|
@@ -203,6 +203,43 @@ paths:
|
||||
description: The user's notebook server has stopped
|
||||
'202':
|
||||
description: The user's notebook server has not yet stopped as it is taking a while to stop
|
||||
/users/{name}/servers/{server_name}:
|
||||
post:
|
||||
summary: Start a user's single-user named-server notebook server
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
- name: server_name
|
||||
description: name given to a named-server
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
'201':
|
||||
description: The user's notebook named-server has started
|
||||
'202':
|
||||
description: The user's notebook named-server has not yet started, but has been requested
|
||||
delete:
|
||||
summary: Stop a user's named-server
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
- name: server_name
|
||||
description: name given to a named-server
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: The user's notebook named-server has stopped
|
||||
'202':
|
||||
description: The user's notebook named-server has not yet stopped as it is taking a while to stop
|
||||
/users/{name}/admin-access:
|
||||
post:
|
||||
summary: Grant admin access to this user's notebook server
|
||||
|
@@ -3,6 +3,7 @@
|
||||
Project Jupyter thanks the following people for their help and
|
||||
contribution on JupyterHub:
|
||||
|
||||
- Analect
|
||||
- anderbubble
|
||||
- apetresc
|
||||
- barrachri
|
||||
|
@@ -123,6 +123,11 @@ via other mechanisms. One such example is using [GitHub OAuth][].
|
||||
|
||||
Because the username is passed from the Authenticator to the Spawner,
|
||||
a custom Authenticator and Spawner are often used together.
|
||||
For example, the Authenticator methods, [pre_spawn_start(user, spawner)][]
|
||||
and [post_spawn_stop(user, spawner)][], are hooks that can be used to do
|
||||
auth-related startup (e.g. opening PAM sessions) and cleanup
|
||||
(e.g. closing PAM sessions).
|
||||
|
||||
|
||||
See a list of custom Authenticators [on the wiki](https://github.com/jupyterhub/jupyterhub/wiki/Authenticators).
|
||||
|
||||
@@ -140,3 +145,5 @@ Beginning with version 0.8, JupyterHub is an OAuth provider.
|
||||
[OAuth]: https://en.wikipedia.org/wiki/OAuth
|
||||
[GitHub OAuth]: https://developer.github.com/v3/oauth/
|
||||
[OAuthenticator]: https://github.com/jupyterhub/oauthenticator
|
||||
[pre_spawn_start(user, spawner)]: http://jupyterhub.readthedocs.io/en/latest/api/auth.html#jupyterhub.auth.Authenticator.pre_spawn_start
|
||||
[post_spawn_stop(user, spawner)]: http://jupyterhub.readthedocs.io/en/latest/api/auth.html#jupyterhub.auth.Authenticator.post_spawn_stop
|
||||
|
@@ -49,9 +49,6 @@ c.JupyterHub.cookie_secret_file = pjoin(runtime_dir, 'cookie_secret')
|
||||
c.JupyterHub.db_url = pjoin(runtime_dir, 'jupyterhub.sqlite')
|
||||
# or `--db=/path/to/jupyterhub.sqlite` on the command-line
|
||||
|
||||
# put the log file in /var/log
|
||||
c.JupyterHub.extra_log_file = '/var/log/jupyterhub.log'
|
||||
|
||||
# use GitHub OAuthenticator for local users
|
||||
c.JupyterHub.authenticator_class = 'oauthenticator.LocalGitHubOAuthenticator'
|
||||
c.GitHubOAuthenticator.oauth_callback_url = os.environ['OAUTH_CALLBACK_URL']
|
||||
@@ -79,7 +76,8 @@ export GITHUB_CLIENT_ID=github_id
|
||||
export GITHUB_CLIENT_SECRET=github_secret
|
||||
export OAUTH_CALLBACK_URL=https://example.com/hub/oauth_callback
|
||||
export CONFIGPROXY_AUTH_TOKEN=super-secret
|
||||
jupyterhub -f /etc/jupyterhub/jupyterhub_config.py
|
||||
# append log output to log file /var/log/jupyterhub.log
|
||||
jupyterhub -f /etc/jupyterhub/jupyterhub_config.py &>> /var/log/jupyterhub.log
|
||||
```
|
||||
|
||||
## Using nginx reverse proxy
|
||||
|
@@ -119,6 +119,55 @@ token does **not** authorize access to the [Jupyter Notebook REST API][]
|
||||
provided by notebook servers managed by JupyterHub. A different token is used
|
||||
to access the **Jupyter Notebook** API.
|
||||
|
||||
## Enabling users to spawn multiple named-servers via the API
|
||||
|
||||
With JupyterHub version 0.8, support for multiple servers per user has landed.
|
||||
Prior to that, each user could only launch a single default server via the API
|
||||
like this:
|
||||
|
||||
```bash
|
||||
curl -X POST -H "Authorization: token <token>" "http://127.0.0.1:8081/hub/api/users/<user>/server"
|
||||
```
|
||||
|
||||
With the named-server functionality, it's now possible to launch more than one
|
||||
specifically named servers against a given user. This could be used, for instance,
|
||||
to launch each server based on a different image.
|
||||
|
||||
First you must enable named-servers by including the following setting in the `jupyterhub_config.py` file.
|
||||
|
||||
`c.JupyterHub.allow_named_servers = True`
|
||||
|
||||
If using the [zero-to-jupyterhub-k8s](https://github.com/jupyterhub/zero-to-jupyterhub-k8s) set-up to run JupyterHub,
|
||||
then instead of editing the `jupyterhub_config.py` file directly, you could pass
|
||||
the following as part of the `config.yaml` file, as per the [tutorial](https://zero-to-jupyterhub.readthedocs.io/en/latest/):
|
||||
|
||||
```bash
|
||||
hub:
|
||||
extraConfig: |
|
||||
c.JupyterHub.allow_named_servers = True
|
||||
```
|
||||
|
||||
With that setting in place, a new named-server is activated like this:
|
||||
```bash
|
||||
curl -X POST -H "Authorization: token <token>" "http://127.0.0.1:8081/hub/api/users/<user>/servers/<serverA>"
|
||||
curl -X POST -H "Authorization: token <token>" "http://127.0.0.1:8081/hub/api/users/<user>/servers/<serverB>"
|
||||
```
|
||||
|
||||
The same servers can be stopped by substituting `DELETE` for `POST` above.
|
||||
|
||||
### Some caveats for using named-servers
|
||||
|
||||
The named-server capabilities are not fully implemented for JupyterHub as yet.
|
||||
While it's possible to start/stop a server via the API, the UI on the
|
||||
JupyterHub control-panel has not been implemented, and so it may not be obvious
|
||||
to those viewing the panel that a named-server may be running for a given user.
|
||||
|
||||
For named-servers via the API to work, the spawner used to spawn these servers
|
||||
will need to be able to handle the case of multiple servers per user and ensure
|
||||
uniqueness of names, particularly if servers are spawned via docker containers
|
||||
or kubernetes pods.
|
||||
|
||||
|
||||
## Learn more about the API
|
||||
|
||||
You can see the full [JupyterHub REST API][] for details. This REST API Spec can
|
||||
|
@@ -200,7 +200,9 @@ or via the `JUPYTERHUB_API_TOKEN` environment variable.
|
||||
|
||||
Most of the logic for authentication implementation is found in the
|
||||
[`HubAuth.user_for_cookie`](services.auth.html#jupyterhub.services.auth.HubAuth.user_for_cookie)
|
||||
method, which makes a request of the Hub, and returns:
|
||||
and in the
|
||||
[`HubAuth.user_for_token`](services.auth.html#jupyterhub.services.auth.HubAuth.user_for_token)
|
||||
methods, which makes a request of the Hub, and returns:
|
||||
|
||||
- None, if no user could be identified, or
|
||||
- a dict of the following form:
|
||||
@@ -252,8 +254,11 @@ def authenticated(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
cookie = request.cookies.get(auth.cookie_name)
|
||||
token = request.headers.get(auth.auth_header_name)
|
||||
if cookie:
|
||||
user = auth.user_for_cookie(cookie)
|
||||
elif token:
|
||||
user = auth.user_for_token(token)
|
||||
else:
|
||||
user = None
|
||||
if user:
|
||||
|
@@ -40,8 +40,11 @@ from tornado.options import define, options, parse_command_line
|
||||
|
||||
|
||||
@coroutine
|
||||
def cull_idle(url, api_token, timeout):
|
||||
"""cull idle single-user servers"""
|
||||
def cull_idle(url, api_token, timeout, cull_users=False):
|
||||
"""Shutdown idle single-user servers
|
||||
|
||||
If cull_users, inactive *users* will be deleted as well.
|
||||
"""
|
||||
auth_header = {
|
||||
'Authorization': 'token %s' % api_token
|
||||
}
|
||||
@@ -54,26 +57,50 @@ def cull_idle(url, api_token, timeout):
|
||||
resp = yield client.fetch(req)
|
||||
users = json.loads(resp.body.decode('utf8', 'replace'))
|
||||
futures = []
|
||||
for user in users:
|
||||
last_activity = parse_date(user['last_activity'])
|
||||
if user['server'] and last_activity < cull_limit:
|
||||
app_log.info("Culling %s (inactive since %s)", user['name'], last_activity)
|
||||
|
||||
@coroutine
|
||||
def cull_one(user, last_activity):
|
||||
"""cull one user"""
|
||||
|
||||
# shutdown server first. Hub doesn't allow deleting users with running servers.
|
||||
if user['server']:
|
||||
app_log.info("Culling server for %s (inactive since %s)", user['name'], last_activity)
|
||||
req = HTTPRequest(url=url + '/users/%s/server' % user['name'],
|
||||
method='DELETE',
|
||||
headers=auth_header,
|
||||
)
|
||||
futures.append((user['name'], client.fetch(req)))
|
||||
elif user['server'] and last_activity > cull_limit:
|
||||
yield client.fetch(req)
|
||||
if cull_users:
|
||||
app_log.info("Culling user %s (inactive since %s)", user['name'], last_activity)
|
||||
req = HTTPRequest(url=url + '/users/%s' % user['name'],
|
||||
method='DELETE',
|
||||
headers=auth_header,
|
||||
)
|
||||
yield client.fetch(req)
|
||||
|
||||
for user in users:
|
||||
if not user['server'] and not cull_users:
|
||||
# server not running and not culling users, nothing to do
|
||||
continue
|
||||
last_activity = parse_date(user['last_activity'])
|
||||
if last_activity < cull_limit:
|
||||
futures.append((user['name'], cull_one(user, last_activity)))
|
||||
else:
|
||||
app_log.debug("Not culling %s (active since %s)", user['name'], last_activity)
|
||||
|
||||
for (name, f) in futures:
|
||||
yield f
|
||||
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('cull_users', default=False,
|
||||
help="""Cull users in addition to servers.
|
||||
This is for use in temporary-user cases such as tmpnb.""",
|
||||
)
|
||||
|
||||
parse_command_line()
|
||||
if not options.cull_every:
|
||||
@@ -82,7 +109,7 @@ if __name__ == '__main__':
|
||||
api_token = os.environ['JUPYTERHUB_API_TOKEN']
|
||||
|
||||
loop = IOLoop.current()
|
||||
cull = lambda : cull_idle(options.url, api_token, options.timeout)
|
||||
cull = lambda : cull_idle(options.url, api_token, options.timeout, options.cull_users)
|
||||
# run once before scheduling periodic call
|
||||
loop.run_sync(cull)
|
||||
# schedule periodic cull
|
||||
|
@@ -28,8 +28,11 @@ def authenticated(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
cookie = request.cookies.get(auth.cookie_name)
|
||||
token = request.headers.get(auth.auth_header_name)
|
||||
if cookie:
|
||||
user = auth.user_for_cookie(cookie)
|
||||
elif token:
|
||||
user = auth.user_for_token(token)
|
||||
else:
|
||||
user = None
|
||||
if user:
|
||||
|
@@ -59,7 +59,7 @@ def oauth_callback():
|
||||
# validate state field
|
||||
arg_state = request.args.get('state', None)
|
||||
cookie_state = request.cookies.get(auth.state_cookie_name)
|
||||
if arg_state != cookie_state:
|
||||
if arg_state is None or arg_state != cookie_state:
|
||||
# state doesn't match
|
||||
return 403
|
||||
|
||||
|
@@ -7,7 +7,7 @@ version_info = (
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
'b4',
|
||||
'rc2',
|
||||
)
|
||||
|
||||
__version__ = '.'.join(map(str, version_info))
|
||||
|
@@ -12,9 +12,16 @@ config = context.config
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if 'jupyterhub' in sys.modules:
|
||||
from traitlets.config import MultipleInstanceError
|
||||
from jupyterhub.app import JupyterHub
|
||||
app = None
|
||||
if JupyterHub.initialized():
|
||||
app = JupyterHub.instance()
|
||||
try:
|
||||
app = JupyterHub.instance()
|
||||
except MultipleInstanceError:
|
||||
# could have been another Application
|
||||
pass
|
||||
if app is not None:
|
||||
alembic_logger = logging.getLogger('alembic')
|
||||
alembic_logger.propagate = True
|
||||
alembic_logger.parent = app.log
|
||||
|
@@ -114,7 +114,7 @@ class APIHandler(BaseHandler):
|
||||
if spawner.pending:
|
||||
s['pending'] = spawner.pending
|
||||
if spawner.server:
|
||||
s['url'] = user.url + name + '/'
|
||||
s['url'] = url_path_join(user.url, name, '/')
|
||||
return model
|
||||
|
||||
def group_model(self, group):
|
||||
|
@@ -185,8 +185,6 @@ class UserServerAPIHandler(APIHandler):
|
||||
user = self.find_user(name)
|
||||
if server_name and not self.allow_named_servers:
|
||||
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]
|
||||
pending = spawner.pending
|
||||
if pending == 'spawn':
|
||||
|
@@ -801,12 +801,10 @@ class JupyterHub(Application):
|
||||
self.handlers = self.add_url_prefix(self.hub_prefix, h)
|
||||
# some extra handlers, outside hub_prefix
|
||||
self.handlers.extend([
|
||||
(r"%s" % self.hub_prefix.rstrip('/'), web.RedirectHandler,
|
||||
{
|
||||
"url": self.hub_prefix,
|
||||
"permanent": False,
|
||||
}
|
||||
),
|
||||
# add trailing / to `/hub`
|
||||
(self.hub_prefix.rstrip('/'), handlers.AddSlashHandler),
|
||||
# add trailing / to ``/user|services/:name`
|
||||
(r"%s(user|services)/([^/]+)" % self.base_url, handlers.AddSlashHandler),
|
||||
(r"(?!%s).*" % self.hub_prefix, handlers.PrefixRedirectHandler),
|
||||
(r'(.*)', handlers.Template404),
|
||||
])
|
||||
@@ -1221,7 +1219,7 @@ class JupyterHub(Application):
|
||||
status = yield spawner.poll()
|
||||
except Exception:
|
||||
self.log.exception("Failed to poll spawner for %s, assuming the spawner is not running.",
|
||||
user.name if name else '%s|%s' % (user.name, name))
|
||||
spawner._log_name)
|
||||
status = -1
|
||||
|
||||
if status is None:
|
||||
@@ -1232,11 +1230,13 @@ class JupyterHub(Application):
|
||||
# user not running. This is expected if server is None,
|
||||
# but indicates the user's server died while the Hub wasn't running
|
||||
# if spawner.server is defined.
|
||||
log = self.log.warning if spawner.server else self.log.debug
|
||||
log("%s not running.", user.name)
|
||||
# remove all server or servers entry from db related to the user
|
||||
if spawner.server:
|
||||
self.log.warning("%s appears to have stopped while the Hub was down", spawner._log_name)
|
||||
# remove server entry from db
|
||||
db.delete(spawner.orm_spawner.server)
|
||||
spawner.server = None
|
||||
else:
|
||||
self.log.debug("%s not running", spawner._log_name)
|
||||
db.commit()
|
||||
|
||||
user_summaries.append(_user_summary(user))
|
||||
|
@@ -20,7 +20,7 @@ from .. import __version__
|
||||
from .. import orm
|
||||
from ..objects import Server
|
||||
from ..spawner import LocalProcessSpawner
|
||||
from ..utils import default_server_name, url_path_join
|
||||
from ..utils import url_path_join
|
||||
|
||||
# pattern for the authentication token header
|
||||
auth_header_pat = re.compile(r'^(?:token|bearer)\s+([^\s]+)$', flags=re.IGNORECASE)
|
||||
@@ -376,9 +376,10 @@ class BaseHandler(RequestHandler):
|
||||
|
||||
@gen.coroutine
|
||||
def spawn_single_user(self, user, server_name='', options=None):
|
||||
# in case of error, include 'try again from /hub/home' message
|
||||
self.extra_error_html = self.spawn_home_error
|
||||
|
||||
user_server_name = 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)
|
||||
@@ -440,11 +441,7 @@ class BaseHandler(RequestHandler):
|
||||
otherwise it is called immediately.
|
||||
"""
|
||||
# wait for spawn Future
|
||||
try:
|
||||
yield spawn_future
|
||||
except Exception:
|
||||
spawner._spawn_pending = False
|
||||
raise
|
||||
yield spawn_future
|
||||
toc = IOLoop.current().time()
|
||||
self.log.info("User %s took %.3f seconds to start", user_server_name, toc-tic)
|
||||
self.statsd.timing('spawner.success', (toc - tic) * 1000)
|
||||
@@ -459,10 +456,22 @@ class BaseHandler(RequestHandler):
|
||||
spawner.add_poll_callback(self.user_stopped, user, server_name)
|
||||
finally:
|
||||
spawner._proxy_pending = False
|
||||
spawner._spawn_pending = False
|
||||
|
||||
# hook up spawner._spawn_future so that other requests can await
|
||||
# this result
|
||||
finish_spawn_future = spawner._spawn_future = finish_user_spawn()
|
||||
def _clear_spawn_future(f):
|
||||
# clear spawner._spawn_future when it's done
|
||||
# keep an exception around, though, to prevent repeated implicit spawns
|
||||
# if spawn is failing
|
||||
if f.exception() is None:
|
||||
spawner._spawn_future = None
|
||||
# Now we're all done. clear _spawn_pending flag
|
||||
spawner._spawn_pending = False
|
||||
finish_spawn_future.add_done_callback(_clear_spawn_future)
|
||||
|
||||
try:
|
||||
yield gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), finish_user_spawn())
|
||||
yield gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), finish_spawn_future)
|
||||
except gen.TimeoutError:
|
||||
# waiting_for_response indicates server process has started,
|
||||
# but is yet to become responsive.
|
||||
@@ -479,7 +488,8 @@ class BaseHandler(RequestHandler):
|
||||
if status is not None:
|
||||
toc = IOLoop.current().time()
|
||||
self.statsd.timing('spawner.failure', (toc - tic) * 1000)
|
||||
raise web.HTTPError(500, "Spawner failed to start [status=%s]" % status)
|
||||
raise web.HTTPError(500, "Spawner failed to start [status=%s]. The logs for %s may contain details." % (
|
||||
status, spawner._log_name))
|
||||
|
||||
if spawner._waiting_for_response:
|
||||
# hit timeout waiting for response, but server's running.
|
||||
@@ -535,6 +545,7 @@ class BaseHandler(RequestHandler):
|
||||
spawner._stop_pending = False
|
||||
toc = IOLoop.current().time()
|
||||
self.log.info("User %s server took %.3f seconds to stop", user.name, toc - tic)
|
||||
self.statsd.timing('spawner.stop', (toc - tic) * 1000)
|
||||
|
||||
try:
|
||||
yield gen.with_timeout(timedelta(seconds=self.slow_stop_timeout), stop())
|
||||
@@ -549,6 +560,19 @@ class BaseHandler(RequestHandler):
|
||||
# template rendering
|
||||
#---------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def spawn_home_error(self):
|
||||
"""Extra message pointing users to try spawning again from /hub/home.
|
||||
|
||||
Should be added to `self.extra_error_html` for any handler
|
||||
that could serve a failed spawn message.
|
||||
"""
|
||||
home = url_path_join(self.hub.base_url, 'home')
|
||||
return (
|
||||
"You can try restarting your server from the "
|
||||
"<a href='{home}'>home page</a>.".format(home=home)
|
||||
)
|
||||
|
||||
def get_template(self, name):
|
||||
"""Return the jinja template object for a given name"""
|
||||
return self.settings['jinja2_env'].get_template(name)
|
||||
@@ -596,6 +620,7 @@ class BaseHandler(RequestHandler):
|
||||
status_code=status_code,
|
||||
status_message=status_message,
|
||||
message=message,
|
||||
extra_error_html=getattr(self, 'extra_error_html', ''),
|
||||
exception=exception,
|
||||
)
|
||||
|
||||
@@ -649,10 +674,13 @@ class UserSpawnHandler(BaseHandler):
|
||||
current_user = self.get_current_user()
|
||||
|
||||
if current_user and current_user.name == name:
|
||||
# if spawning fails for any reason, point users to /hub/home to retry
|
||||
self.extra_error_html = self.spawn_home_error
|
||||
|
||||
# If people visit /user/:name directly on the Hub,
|
||||
# the redirects will just loop, because the proxy is bypassed.
|
||||
# Try to check for that and warn,
|
||||
# though the user-facing behavior is unchainged
|
||||
# though the user-facing behavior is unchanged
|
||||
host_info = urlparse(self.request.full_url())
|
||||
port = host_info.port
|
||||
if not port:
|
||||
@@ -664,8 +692,34 @@ class UserSpawnHandler(BaseHandler):
|
||||
Make sure to connect to the proxied public URL %s
|
||||
""", self.request.full_url(), self.proxy.public_url)
|
||||
|
||||
# logged in as correct user, spawn the server
|
||||
# logged in as correct user, check for pending spawn
|
||||
spawner = current_user.spawner
|
||||
|
||||
# First, check for previous failure.
|
||||
if (
|
||||
not spawner.active
|
||||
and spawner._spawn_future
|
||||
and spawner._spawn_future.done()
|
||||
and spawner._spawn_future.exception()
|
||||
):
|
||||
# Condition: spawner not active and _spawn_future exists and contains an Exception
|
||||
# Implicit spawn on /user/:name is not allowed if the user's last spawn failed.
|
||||
# We should point the user to Home if the most recent spawn failed.
|
||||
self.log.error("Preventing implicit spawn for %s because last spawn failed: %s",
|
||||
spawner._log_name, spawner._spawn_future.exception())
|
||||
raise spawner._spawn_future.exception()
|
||||
|
||||
# check for pending spawn
|
||||
if spawner.pending and spawner._spawn_future:
|
||||
# wait on the pending spawn
|
||||
self.log.debug("Waiting for %s pending %s", spawner._log_name, spawner.pending)
|
||||
try:
|
||||
yield gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), spawner._spawn_future)
|
||||
except gen.TimeoutError:
|
||||
self.log.info("Pending spawn for %s didn't finish in %.1f seconds", spawner._log_name, self.slow_spawn_timeout)
|
||||
pass
|
||||
|
||||
# we may have waited above, check pending again:
|
||||
if spawner.pending:
|
||||
self.log.info("%s is pending %s", spawner._log_name, spawner.pending)
|
||||
# spawn has started, but not finished
|
||||
@@ -679,6 +733,8 @@ class UserSpawnHandler(BaseHandler):
|
||||
status = yield spawner.poll()
|
||||
else:
|
||||
status = 0
|
||||
|
||||
# server is not running, trigger spawn
|
||||
if status is not None:
|
||||
if spawner.options_form:
|
||||
self.redirect(url_concat(url_path_join(self.hub.base_url, 'spawn'),
|
||||
@@ -687,6 +743,15 @@ class UserSpawnHandler(BaseHandler):
|
||||
else:
|
||||
yield self.spawn_single_user(current_user)
|
||||
|
||||
# spawn didn't finish, show pending page
|
||||
if spawner.pending:
|
||||
self.log.info("%s is pending %s", spawner._log_name, spawner.pending)
|
||||
# spawn has started, but not finished
|
||||
self.statsd.incr('redirects.user_spawn_pending', 1)
|
||||
html = self.render_template("spawn_pending.html", user=current_user)
|
||||
self.finish(html)
|
||||
return
|
||||
|
||||
# We do exponential backoff here - since otherwise we can get stuck in a redirect loop!
|
||||
# This is important in many distributed proxy implementations - those are often eventually
|
||||
# consistent and can take upto a couple of seconds to actually apply throughout the cluster.
|
||||
@@ -786,6 +851,13 @@ class CSPReportHandler(BaseHandler):
|
||||
self.statsd.incr('csp_report')
|
||||
|
||||
|
||||
class AddSlashHandler(BaseHandler):
|
||||
"""Handler for adding trailing slash to URLs that need them"""
|
||||
def get(self, *args):
|
||||
src = urlparse(self.request.uri)
|
||||
dest = src._replace(path=src.path + '/')
|
||||
self.redirect(urlunparse(dest))
|
||||
|
||||
default_handlers = [
|
||||
(r'/user/([^/]+)(/.*)?', UserSpawnHandler),
|
||||
(r'/user-redirect/(.*)?', UserRedirectHandler),
|
||||
|
@@ -67,9 +67,13 @@ class HomeHandler(BaseHandler):
|
||||
if user.running:
|
||||
# trigger poll_and_notify event in case of a server that died
|
||||
yield user.spawner.poll_and_notify()
|
||||
# send the user to /spawn if they aren't running,
|
||||
# to establish that this is an explicit spawn request rather
|
||||
# than an implicit one, which can be caused by any link to `/user/:name`
|
||||
url = user.url if user.running else url_path_join(self.hub.base_url, 'spawn')
|
||||
html = self.render_template('home.html',
|
||||
user=user,
|
||||
url=user.url,
|
||||
url=url,
|
||||
)
|
||||
self.finish(html)
|
||||
|
||||
@@ -92,7 +96,10 @@ class SpawnHandler(BaseHandler):
|
||||
|
||||
@web.authenticated
|
||||
def get(self):
|
||||
"""GET renders form for spawning with user-specified options"""
|
||||
"""GET renders form for spawning with user-specified options
|
||||
|
||||
or triggers spawn via redirect if there is no form.
|
||||
"""
|
||||
user = self.get_current_user()
|
||||
if not self.allow_named_servers and user.running:
|
||||
url = user.url
|
||||
@@ -102,7 +109,12 @@ class SpawnHandler(BaseHandler):
|
||||
if user.spawner.options_form:
|
||||
self.finish(self._render_form())
|
||||
else:
|
||||
# not running, no form. Trigger spawn.
|
||||
# Explicit spawn request: clear _spawn_future
|
||||
# which may have been saved to prevent implicit spawns
|
||||
# after a failure.
|
||||
if user.spawner._spawn_future and user.spawner._spawn_future.done():
|
||||
user.spawner._spawn_future = None
|
||||
# not running, no form. Trigger spawn by redirecting to /user/:name
|
||||
self.redirect(user.url)
|
||||
|
||||
@web.authenticated
|
||||
|
@@ -177,7 +177,7 @@ class Spawner(Base):
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
|
||||
|
||||
server_id = Column(Integer, ForeignKey('servers.id'))
|
||||
server_id = Column(Integer, ForeignKey('servers.id', ondelete='SET NULL'))
|
||||
server = relationship(Server)
|
||||
|
||||
state = Column(JSONDict)
|
||||
@@ -213,7 +213,7 @@ class Service(Base):
|
||||
api_tokens = relationship("APIToken", backref="service")
|
||||
|
||||
# service-specific interface
|
||||
_server_id = Column(Integer, ForeignKey('servers.id'))
|
||||
_server_id = Column(Integer, ForeignKey('servers.id', ondelete='SET NULL'))
|
||||
server = relationship(Server, primaryjoin=_server_id == Server.id)
|
||||
pid = Column(Integer)
|
||||
|
||||
|
@@ -13,8 +13,10 @@ authenticate with the Hub.
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import socket
|
||||
import string
|
||||
import time
|
||||
from urllib.parse import quote, urlencode
|
||||
import uuid
|
||||
@@ -531,22 +533,37 @@ class HubOAuth(HubAuth):
|
||||
-------
|
||||
state (str): The OAuth state that has been stored in the cookie (url safe, base64-encoded)
|
||||
"""
|
||||
b64_state = self.generate_state(next_url)
|
||||
extra_state = {}
|
||||
if handler.get_cookie(self.state_cookie_name):
|
||||
# oauth state cookie is already set
|
||||
# use a randomized cookie suffix to avoid collisions
|
||||
# in case of concurrent logins
|
||||
app_log.warning("Detected unused OAuth state cookies")
|
||||
cookie_suffix = ''.join(random.choice(string.ascii_letters) for i in range(8))
|
||||
cookie_name = '{}-{}'.format(self.state_cookie_name, cookie_suffix)
|
||||
extra_state['cookie_name'] = cookie_name
|
||||
else:
|
||||
cookie_name = self.state_cookie_name
|
||||
b64_state = self.generate_state(next_url, **extra_state)
|
||||
kwargs = {
|
||||
'path': self.base_url,
|
||||
'httponly': True,
|
||||
'expires_days': 1,
|
||||
# Expire oauth state cookie in ten minutes.
|
||||
# Usually this will be cleared by completed login
|
||||
# in less than a few seconds.
|
||||
# OAuth that doesn't complete shouldn't linger too long.
|
||||
'max_age': 600,
|
||||
}
|
||||
if handler.request.protocol == 'https':
|
||||
kwargs['secure'] = True
|
||||
handler.set_secure_cookie(
|
||||
self.state_cookie_name,
|
||||
cookie_name,
|
||||
b64_state,
|
||||
**kwargs
|
||||
)
|
||||
return b64_state
|
||||
|
||||
def generate_state(self, next_url=None):
|
||||
def generate_state(self, next_url=None, **extra_state):
|
||||
"""Generate a state string, given a next_url redirect target
|
||||
|
||||
Parameters
|
||||
@@ -557,16 +574,27 @@ class HubOAuth(HubAuth):
|
||||
-------
|
||||
state (str): The base64-encoded state string.
|
||||
"""
|
||||
return self._encode_state({
|
||||
state = {
|
||||
'uuid': uuid.uuid4().hex,
|
||||
'next_url': next_url
|
||||
})
|
||||
'next_url': next_url,
|
||||
}
|
||||
state.update(extra_state)
|
||||
return self._encode_state(state)
|
||||
|
||||
def get_next_url(self, b64_state=''):
|
||||
"""Get the next_url for redirection, given an encoded OAuth state"""
|
||||
state = self._decode_state(b64_state)
|
||||
return state.get('next_url') or self.base_url
|
||||
|
||||
def get_state_cookie_name(self, b64_state=''):
|
||||
"""Get the cookie name for oauth state, given an encoded OAuth state
|
||||
|
||||
Cookie name is stored in the state itself because the cookie name
|
||||
is randomized to deal with races between concurrent oauth sequences.
|
||||
"""
|
||||
state = self._decode_state(b64_state)
|
||||
return state.get('cookie_name') or self.state_cookie_name
|
||||
|
||||
def set_cookie(self, handler, access_token):
|
||||
"""Set a cookie recording OAuth result"""
|
||||
kwargs = {
|
||||
@@ -657,13 +685,12 @@ class HubAuthenticated(object):
|
||||
def get_login_url(self):
|
||||
"""Return the Hub's login URL"""
|
||||
login_url = self.hub_auth.login_url
|
||||
app_log.debug("Redirecting to login url: %s", login_url)
|
||||
if isinstance(self.hub_auth, HubOAuthenticated):
|
||||
if isinstance(self.hub_auth, HubOAuth):
|
||||
# add state argument to OAuth url
|
||||
state = self.hub_auth.set_state_cookie(self, next_url=self.request.uri)
|
||||
return url_concat(login_url, {'state': state})
|
||||
else:
|
||||
return login_url
|
||||
login_url = url_concat(login_url, {'state': state})
|
||||
app_log.debug("Redirecting to login url: %s", login_url)
|
||||
return login_url
|
||||
|
||||
def check_hub_user(self, model):
|
||||
"""Check whether Hub-authenticated user or service should be allowed.
|
||||
@@ -770,18 +797,19 @@ class HubOAuthCallbackHandler(HubOAuthenticated, RequestHandler):
|
||||
|
||||
# validate OAuth state
|
||||
arg_state = self.get_argument("state", None)
|
||||
cookie_state = self.get_secure_cookie(self.hub_auth.state_cookie_name)
|
||||
next_url = None
|
||||
if arg_state or cookie_state:
|
||||
# clear cookie state now that we've consumed it
|
||||
self.clear_cookie(self.hub_auth.state_cookie_name)
|
||||
if isinstance(cookie_state, bytes):
|
||||
cookie_state = cookie_state.decode('ascii', 'replace')
|
||||
# check that state matches
|
||||
if arg_state != cookie_state:
|
||||
app_log.debug("oauth state %r != %r", arg_state, cookie_state)
|
||||
raise HTTPError(403, "oauth state does not match")
|
||||
next_url = self.hub_auth.get_next_url(cookie_state)
|
||||
if arg_state is None:
|
||||
raise HTTPError("oauth state is missing. Try logging in again.")
|
||||
cookie_name = self.hub_auth.get_state_cookie_name(arg_state)
|
||||
cookie_state = self.get_secure_cookie(cookie_name)
|
||||
# clear cookie state now that we've consumed it
|
||||
self.clear_cookie(cookie_name, path=self.hub_auth.base_url)
|
||||
if isinstance(cookie_state, bytes):
|
||||
cookie_state = cookie_state.decode('ascii', 'replace')
|
||||
# check that state matches
|
||||
if arg_state != cookie_state:
|
||||
app_log.warning("oauth state %r != %r", arg_state, cookie_state)
|
||||
raise HTTPError(403, "oauth state does not match. Try logging in again.")
|
||||
next_url = self.hub_auth.get_next_url(cookie_state)
|
||||
# TODO: make async (in a Thread?)
|
||||
token = self.hub_auth.token_for_code(code)
|
||||
user_model = self.hub_auth.user_for_token(token)
|
||||
|
@@ -301,5 +301,8 @@ class Service(LoggingConfigurable):
|
||||
if not self.managed:
|
||||
raise RuntimeError("Cannot stop unmanaged service %s" % self)
|
||||
if self.spawner:
|
||||
if self.orm.server:
|
||||
self.db.delete(self.orm.server)
|
||||
self.db.commit()
|
||||
self.spawner.stop_polling()
|
||||
return self.spawner.stop()
|
||||
|
@@ -144,11 +144,13 @@ page_template = """
|
||||
{% block header_buttons %}
|
||||
{{super()}}
|
||||
|
||||
<a href='{{hub_control_panel_url}}'
|
||||
class='btn btn-default btn-sm navbar-btn pull-right'
|
||||
style='margin-right: 4px; margin-left: 2px;'
|
||||
>
|
||||
Control Panel</a>
|
||||
<span>
|
||||
<a href='{{hub_control_panel_url}}'
|
||||
class='btn btn-default btn-sm navbar-btn pull-right'
|
||||
style='margin-right: 4px; margin-left: 2px;'>
|
||||
Control Panel
|
||||
</a>
|
||||
</span>
|
||||
{% endblock %}
|
||||
{% block logo %}
|
||||
<img src='{{logo_url}}' alt='Jupyter Notebook'/>
|
||||
|
@@ -54,6 +54,7 @@ class Spawner(LoggingConfigurable):
|
||||
_proxy_pending = False
|
||||
_waiting_for_response = False
|
||||
_jupyterhub_version = None
|
||||
_spawn_future = None
|
||||
|
||||
@property
|
||||
def _log_name(self):
|
||||
@@ -838,7 +839,7 @@ class LocalProcessSpawner(Spawner):
|
||||
This is the default spawner for JupyterHub.
|
||||
"""
|
||||
|
||||
INTERRUPT_TIMEOUT = Integer(10,
|
||||
interrupt_timeout = Integer(10,
|
||||
help="""
|
||||
Seconds to wait for single-user server process to halt after SIGINT.
|
||||
|
||||
@@ -846,7 +847,7 @@ class LocalProcessSpawner(Spawner):
|
||||
"""
|
||||
).tag(config=True)
|
||||
|
||||
TERM_TIMEOUT = Integer(5,
|
||||
term_timeout = Integer(5,
|
||||
help="""
|
||||
Seconds to wait for single-user server process to halt after SIGTERM.
|
||||
|
||||
@@ -854,7 +855,7 @@ class LocalProcessSpawner(Spawner):
|
||||
"""
|
||||
).tag(config=True)
|
||||
|
||||
KILL_TIMEOUT = Integer(5,
|
||||
kill_timeout = Integer(5,
|
||||
help="""
|
||||
Seconds to wait for process to halt after SIGKILL before giving up.
|
||||
|
||||
@@ -1070,7 +1071,7 @@ class LocalProcessSpawner(Spawner):
|
||||
return
|
||||
self.log.debug("Interrupting %i", self.pid)
|
||||
yield self._signal(signal.SIGINT)
|
||||
yield self.wait_for_death(self.INTERRUPT_TIMEOUT)
|
||||
yield self.wait_for_death(self.interrupt_timeout)
|
||||
|
||||
# clean shutdown failed, use TERM
|
||||
status = yield self.poll()
|
||||
@@ -1078,7 +1079,7 @@ class LocalProcessSpawner(Spawner):
|
||||
return
|
||||
self.log.debug("Terminating %i", self.pid)
|
||||
yield self._signal(signal.SIGTERM)
|
||||
yield self.wait_for_death(self.TERM_TIMEOUT)
|
||||
yield self.wait_for_death(self.term_timeout)
|
||||
|
||||
# TERM failed, use KILL
|
||||
status = yield self.poll()
|
||||
@@ -1086,7 +1087,7 @@ class LocalProcessSpawner(Spawner):
|
||||
return
|
||||
self.log.debug("Killing %i", self.pid)
|
||||
yield self._signal(signal.SIGKILL)
|
||||
yield self.wait_for_death(self.KILL_TIMEOUT)
|
||||
yield self.wait_for_death(self.kill_timeout)
|
||||
|
||||
status = yield self.poll()
|
||||
if status is None:
|
||||
|
@@ -8,9 +8,11 @@ from subprocess import check_output, Popen, PIPE
|
||||
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
from tornado import gen
|
||||
import pytest
|
||||
|
||||
from .mocking import MockHub
|
||||
from .test_api import add_user
|
||||
from .. import orm
|
||||
from ..app import COOKIE_SECRET_BYTES
|
||||
|
||||
@@ -161,3 +163,57 @@ def test_load_groups():
|
||||
assert gold is not None
|
||||
assert sorted([ u.name for u in gold.users ]) == sorted(to_load['gold'])
|
||||
|
||||
|
||||
@pytest.mark.gen_test
|
||||
def test_resume_spawners(tmpdir, request):
|
||||
if not os.getenv('JUPYTERHUB_TEST_DB_URL'):
|
||||
p = patch.dict(os.environ, {
|
||||
'JUPYTERHUB_TEST_DB_URL': 'sqlite:///%s' % tmpdir.join('jupyterhub.sqlite'),
|
||||
})
|
||||
p.start()
|
||||
request.addfinalizer(p.stop)
|
||||
@gen.coroutine
|
||||
def new_hub():
|
||||
app = MockHub()
|
||||
app.config.ConfigurableHTTPProxy.should_start = False
|
||||
yield app.initialize([])
|
||||
return app
|
||||
app = yield new_hub()
|
||||
db = app.db
|
||||
# spawn a user's server
|
||||
name = 'kurt'
|
||||
user = add_user(db, app, name=name)
|
||||
yield user.spawn()
|
||||
proc = user.spawner.proc
|
||||
assert proc is not None
|
||||
|
||||
# stop the Hub without cleaning up servers
|
||||
app.cleanup_servers = False
|
||||
yield app.stop()
|
||||
|
||||
# proc is still running
|
||||
assert proc.poll() is None
|
||||
|
||||
# resume Hub, should still be running
|
||||
app = yield new_hub()
|
||||
db = app.db
|
||||
user = app.users[name]
|
||||
assert user.running
|
||||
assert user.spawner.server is not None
|
||||
|
||||
# stop the Hub without cleaning up servers
|
||||
app.cleanup_servers = False
|
||||
yield app.stop()
|
||||
|
||||
# stop the server while the Hub is down. BAMF!
|
||||
proc.terminate()
|
||||
proc.wait(timeout=10)
|
||||
assert proc.poll() is not None
|
||||
|
||||
# resume Hub, should be stopped
|
||||
app = yield new_hub()
|
||||
db = app.db
|
||||
user = app.users[name]
|
||||
assert not user.running
|
||||
assert user.spawner.server is None
|
||||
assert list(db.query(orm.Server)) == []
|
||||
|
@@ -46,4 +46,3 @@ def test_upgrade_entrypoint(tmpdir):
|
||||
|
||||
# run tokenapp again, it should work
|
||||
tokenapp.start()
|
||||
|
@@ -17,6 +17,57 @@ def named_servers(app):
|
||||
app.tornado_application.settings[key] = app.tornado_settings[key] = False
|
||||
|
||||
|
||||
@pytest.mark.gen_test
|
||||
def test_default_server(app, named_servers):
|
||||
"""Test the default /users/:user/server handler when named servers are enabled"""
|
||||
username = 'rosie'
|
||||
user = add_user(app.db, app, name=username)
|
||||
r = yield api_request(app, 'users', username, 'server', method='post')
|
||||
assert r.status_code == 201
|
||||
assert r.text == ''
|
||||
|
||||
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': user.url,
|
||||
'servers': {
|
||||
'': {
|
||||
'name': '',
|
||||
'url': user.url,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# now stop the server
|
||||
r = yield api_request(app, 'users', username, 'server', method='delete')
|
||||
assert r.status_code == 204
|
||||
assert r.text == ''
|
||||
|
||||
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': {},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@pytest.mark.gen_test
|
||||
def test_create_named_server(app, named_servers):
|
||||
username = 'walnut'
|
||||
@@ -49,13 +100,13 @@ def test_create_named_server(app, named_servers):
|
||||
'kind': 'user',
|
||||
'admin': False,
|
||||
'pending': None,
|
||||
'server': None,
|
||||
'server': user.url,
|
||||
'servers': {
|
||||
name: {
|
||||
'name': name,
|
||||
'url': url_path_join(user.url, name, '/'),
|
||||
}
|
||||
for name in ['1', servername]
|
||||
for name in ['', servername]
|
||||
},
|
||||
}
|
||||
|
||||
@@ -86,13 +137,13 @@ def test_delete_named_server(app, named_servers):
|
||||
'kind': 'user',
|
||||
'admin': False,
|
||||
'pending': None,
|
||||
'server': None,
|
||||
'server': user.url,
|
||||
'servers': {
|
||||
name: {
|
||||
'name': name,
|
||||
'url': url_path_join(user.url, name, '/'),
|
||||
}
|
||||
for name in ['1']
|
||||
for name in ['']
|
||||
},
|
||||
}
|
||||
|
||||
|
@@ -14,6 +14,7 @@ from .. import objects
|
||||
from .. import crypto
|
||||
from ..user import User
|
||||
from .mocking import MockSpawner
|
||||
from ..emptyclass import EmptyClass
|
||||
|
||||
|
||||
def test_server(db):
|
||||
@@ -167,6 +168,7 @@ def test_spawn_fails(db):
|
||||
user = User(orm_user, {
|
||||
'spawner_class': BadSpawner,
|
||||
'config': None,
|
||||
'statsd': EmptyClass(),
|
||||
})
|
||||
|
||||
with pytest.raises(RuntimeError) as exc:
|
||||
|
@@ -126,7 +126,7 @@ def test_spawn_redirect(app):
|
||||
# should have started server
|
||||
status = yield u.spawner.poll()
|
||||
assert status is None
|
||||
|
||||
|
||||
# test spawn page when server is already running (just redirect)
|
||||
r = yield get_page('spawn', app, cookies=cookies)
|
||||
r.raise_for_status()
|
||||
@@ -134,6 +134,16 @@ def test_spawn_redirect(app):
|
||||
path = urlparse(r.url).path
|
||||
assert path == ujoin(app.base_url, '/user/%s/' % name)
|
||||
|
||||
# stop server to ensure /user/name is handled by the Hub
|
||||
r = yield api_request(app, 'users', name, 'server', method='delete', cookies=cookies)
|
||||
r.raise_for_status()
|
||||
|
||||
# test handing of trailing slash on `/user/name`
|
||||
r = yield get_page('user/' + name, app, hub=False, cookies=cookies)
|
||||
r.raise_for_status()
|
||||
path = urlparse(r.url).path
|
||||
assert path == ujoin(app.base_url, '/user/%s/' % name)
|
||||
|
||||
|
||||
@pytest.mark.gen_test
|
||||
def test_spawn_page(app):
|
||||
|
@@ -16,6 +16,7 @@ import requests_mock
|
||||
from tornado.ioloop import IOLoop
|
||||
from tornado.httpserver import HTTPServer
|
||||
from tornado.web import RequestHandler, Application, authenticated, HTTPError
|
||||
from tornado.httputil import url_concat
|
||||
|
||||
from ..services.auth import _ExpiringDict, HubAuth, HubAuthenticated
|
||||
from ..utils import url_path_join
|
||||
@@ -316,7 +317,8 @@ def test_hubauth_service_token(app, mockservice_url):
|
||||
|
||||
@pytest.mark.gen_test
|
||||
def test_oauth_service(app, mockservice_url):
|
||||
url = url_path_join(public_url(app, mockservice_url) + 'owhoami/')
|
||||
service = mockservice_url
|
||||
url = url_path_join(public_url(app, mockservice_url) + 'owhoami/?arg=x')
|
||||
# first request is only going to set login cookie
|
||||
# FIXME: redirect to originating URL (OAuth loses this info)
|
||||
s = requests.Session()
|
||||
@@ -326,6 +328,14 @@ def test_oauth_service(app, mockservice_url):
|
||||
s_get = lambda *args, **kwargs: async_requests.executor.submit(s.get, *args, **kwargs)
|
||||
r = yield s_get(url)
|
||||
r.raise_for_status()
|
||||
assert r.url == url
|
||||
# verify oauth cookie is set
|
||||
assert 'service-%s' % service.name in set(s.cookies.keys())
|
||||
# verify oauth state cookie has been consumed
|
||||
assert 'service-%s-oauth-state' % service.name not in set(s.cookies.keys())
|
||||
# verify oauth state cookie was set at some point
|
||||
assert set(r.history[0].cookies.keys()) == {'service-%s-oauth-state' % service.name}
|
||||
|
||||
# second request should be authenticated
|
||||
r = yield s_get(url, allow_redirects=False)
|
||||
r.raise_for_status()
|
||||
@@ -340,7 +350,7 @@ def test_oauth_service(app, mockservice_url):
|
||||
# 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 = yield async_requests.get(url_concat(url, {'token': token}))
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
assert reply['name'] == name
|
||||
@@ -349,11 +359,70 @@ def test_oauth_service(app, mockservice_url):
|
||||
assert len(r.cookies) != 0
|
||||
# ensure cookie works in future requests
|
||||
r = yield async_requests.get(
|
||||
public_url(app, mockservice_url) + 'owhoami/',
|
||||
url,
|
||||
cookies=r.cookies,
|
||||
allow_redirects=False,
|
||||
)
|
||||
r.raise_for_status()
|
||||
assert r.url == url
|
||||
reply = r.json()
|
||||
assert reply['name'] == name
|
||||
|
||||
|
||||
@pytest.mark.gen_test
|
||||
def test_oauth_cookie_collision(app, mockservice_url):
|
||||
service = mockservice_url
|
||||
url = url_path_join(public_url(app, mockservice_url) + 'owhoami/')
|
||||
print(url)
|
||||
s = requests.Session()
|
||||
name = 'mypha'
|
||||
s.cookies = yield app.login_user(name)
|
||||
# run session.get in async_requests thread
|
||||
s_get = lambda *args, **kwargs: async_requests.executor.submit(s.get, *args, **kwargs)
|
||||
state_cookie_name = 'service-%s-oauth-state' % service.name
|
||||
service_cookie_name = 'service-%s' % service.name
|
||||
oauth_1 = yield s_get(url, allow_redirects=False)
|
||||
print(oauth_1.headers)
|
||||
print(oauth_1.cookies, oauth_1.url, url)
|
||||
assert state_cookie_name in s.cookies
|
||||
state_cookies = [ s for s in s.cookies.keys() if s.startswith(state_cookie_name) ]
|
||||
# only one state cookie
|
||||
assert state_cookies == [state_cookie_name]
|
||||
state_1 = s.cookies[state_cookie_name]
|
||||
|
||||
# start second oauth login before finishing the first
|
||||
oauth_2 = yield s_get(url, allow_redirects=False)
|
||||
state_cookies = [ s for s in s.cookies.keys() if s.startswith(state_cookie_name) ]
|
||||
assert len(state_cookies) == 2
|
||||
# get the random-suffix cookie name
|
||||
state_cookie_2 = sorted(state_cookies)[-1]
|
||||
# we didn't clobber the default cookie
|
||||
assert s.cookies[state_cookie_name] == state_1
|
||||
|
||||
# finish oauth 2
|
||||
url = oauth_2.headers['Location']
|
||||
if not urlparse(url).netloc:
|
||||
url = public_host(app) + url
|
||||
r = yield s_get(url)
|
||||
r.raise_for_status()
|
||||
# after finishing, state cookie is cleared
|
||||
assert state_cookie_2 not in s.cookies
|
||||
# service login cookie is set
|
||||
assert service_cookie_name in s.cookies
|
||||
service_cookie_2 = s.cookies[service_cookie_name]
|
||||
|
||||
# finish oauth 1
|
||||
url = oauth_1.headers['Location']
|
||||
if not urlparse(url).netloc:
|
||||
url = public_host(app) + url
|
||||
r = yield s_get(url)
|
||||
r.raise_for_status()
|
||||
# after finishing, state cookie is cleared (again)
|
||||
assert state_cookie_name not in s.cookies
|
||||
# service login cookie is set (again, to a different value)
|
||||
assert service_cookie_name in s.cookies
|
||||
assert s.cookies[service_cookie_name] != service_cookie_2
|
||||
|
||||
# after completing both OAuth logins, no OAuth state cookies remain
|
||||
state_cookies = [ s for s in s.cookies.keys() if s.startswith(state_cookie_name) ]
|
||||
assert state_cookies == []
|
||||
|
@@ -51,9 +51,9 @@ def new_spawner(db, **kwargs):
|
||||
kwargs.setdefault('notebook_dir', os.getcwd())
|
||||
kwargs.setdefault('default_url', '/user/{username}/lab')
|
||||
kwargs.setdefault('oauth_client_id', 'mock-client-id')
|
||||
kwargs.setdefault('INTERRUPT_TIMEOUT', 1)
|
||||
kwargs.setdefault('TERM_TIMEOUT', 1)
|
||||
kwargs.setdefault('KILL_TIMEOUT', 1)
|
||||
kwargs.setdefault('interrupt_timeout', 1)
|
||||
kwargs.setdefault('term_timeout', 1)
|
||||
kwargs.setdefault('kill_timeout', 1)
|
||||
kwargs.setdefault('poll_interval', 1)
|
||||
return user._new_spawner('', spawner_class=LocalProcessSpawner, **kwargs)
|
||||
|
||||
@@ -299,7 +299,7 @@ def test_spawner_reuse_api_token(db, app):
|
||||
|
||||
|
||||
@pytest.mark.gen_test
|
||||
def test_spawner_insert_api_token(db, app):
|
||||
def test_spawner_insert_api_token(app):
|
||||
"""Token provided by spawner is not in the db
|
||||
|
||||
Insert token into db as a user-provided token.
|
||||
@@ -326,7 +326,7 @@ def test_spawner_insert_api_token(db, app):
|
||||
|
||||
|
||||
@pytest.mark.gen_test
|
||||
def test_spawner_bad_api_token(db, app):
|
||||
def test_spawner_bad_api_token(app):
|
||||
"""Tokens are revoked when a Spawner gets another user's token"""
|
||||
# we need two users for this one
|
||||
user = add_user(app.db, app, name='antimone')
|
||||
@@ -346,3 +346,37 @@ def test_spawner_bad_api_token(db, app):
|
||||
yield user.spawn()
|
||||
assert orm.APIToken.find(app.db, other_token) is None
|
||||
assert other_user.api_tokens == []
|
||||
|
||||
|
||||
@pytest.mark.gen_test
|
||||
def test_spawner_delete_server(app):
|
||||
"""Test deleting spawner.server
|
||||
|
||||
This can occur during app startup if their server has been deleted.
|
||||
"""
|
||||
db = app.db
|
||||
user = add_user(app.db, app, name='gaston')
|
||||
spawner = user.spawner
|
||||
orm_server = orm.Server()
|
||||
db.add(orm_server)
|
||||
db.commit()
|
||||
server_id = orm_server.id
|
||||
spawner.server = Server.from_orm(orm_server)
|
||||
db.commit()
|
||||
|
||||
assert spawner.server is not None
|
||||
assert spawner.orm_spawner.server is not None
|
||||
|
||||
# trigger delete via db
|
||||
db.delete(spawner.orm_spawner.server)
|
||||
db.commit()
|
||||
assert spawner.orm_spawner.server is None
|
||||
|
||||
# setting server = None also triggers delete
|
||||
spawner.server = None
|
||||
db.commit()
|
||||
# verify that the server was actually deleted from the db
|
||||
assert db.query(orm.Server).filter(orm.Server.id == server_id).first() is None
|
||||
# verify that both ORM and top-level references are None
|
||||
assert spawner.orm_spawner.server is None
|
||||
assert spawner.server is None
|
||||
|
@@ -34,18 +34,27 @@ def test_memoryspec():
|
||||
c = C()
|
||||
|
||||
c.mem = 1024
|
||||
assert isinstance(c.mem, int)
|
||||
assert c.mem == 1024
|
||||
|
||||
c.mem = '1024K'
|
||||
assert isinstance(c.mem, int)
|
||||
assert c.mem == 1024 * 1024
|
||||
|
||||
c.mem = '1024M'
|
||||
assert isinstance(c.mem, int)
|
||||
assert c.mem == 1024 * 1024 * 1024
|
||||
|
||||
c.mem = '1.5M'
|
||||
assert isinstance(c.mem, int)
|
||||
assert c.mem == 1.5 * 1024 * 1024
|
||||
|
||||
c.mem = '1024G'
|
||||
assert isinstance(c.mem, int)
|
||||
assert c.mem == 1024 * 1024 * 1024 * 1024
|
||||
|
||||
c.mem = '1024T'
|
||||
assert isinstance(c.mem, int)
|
||||
assert c.mem == 1024 * 1024 * 1024 * 1024 * 1024
|
||||
|
||||
with pytest.raises(TraitError):
|
||||
|
@@ -48,7 +48,7 @@ class ByteSpecification(Integer):
|
||||
'K': 1024,
|
||||
'M': 1024 * 1024,
|
||||
'G': 1024 * 1024 * 1024,
|
||||
'T': 1024 * 1024 * 1024 * 1024
|
||||
'T': 1024 * 1024 * 1024 * 1024,
|
||||
}
|
||||
|
||||
# Default to allowing None as a value
|
||||
@@ -62,11 +62,15 @@ class ByteSpecification(Integer):
|
||||
If it has one of the suffixes, it is converted into the appropriate
|
||||
pure byte value.
|
||||
"""
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
num = value[:-1]
|
||||
if isinstance(value, (int, float)):
|
||||
return int(value)
|
||||
|
||||
try:
|
||||
num = float(value[:-1])
|
||||
except ValueError:
|
||||
raise TraitError('{val} is not a valid memory specification. Must be an int or a string with suffix K, M, G, T'.format(val=value))
|
||||
suffix = value[-1]
|
||||
if not num.isdigit() and suffix not in ByteSpecification.UNIT_SUFFIXES:
|
||||
if suffix not in self.UNIT_SUFFIXES:
|
||||
raise TraitError('{val} is not a valid memory specification. Must be an int or a string with suffix K, M, G, T'.format(val=value))
|
||||
else:
|
||||
return int(num) * ByteSpecification.UNIT_SUFFIXES[suffix]
|
||||
return int(float(num) * self.UNIT_SUFFIXES[suffix])
|
||||
|
@@ -12,7 +12,7 @@ from tornado import gen
|
||||
from tornado.log import app_log
|
||||
from traitlets import HasTraits, Any, Dict, default
|
||||
|
||||
from .utils import url_path_join, default_server_name
|
||||
from .utils import url_path_join
|
||||
|
||||
from . import orm
|
||||
from ._version import _check_version, __version__
|
||||
@@ -421,10 +421,12 @@ class User(HasTraits):
|
||||
user=self.name, s=spawner.start_timeout,
|
||||
))
|
||||
e.reason = 'timeout'
|
||||
self.settings['statsd'].incr('spawner.failure.timeout')
|
||||
else:
|
||||
self.log.error("Unhandled error starting {user}'s server: {error}".format(
|
||||
user=self.name, error=e,
|
||||
))
|
||||
self.settings['statsd'].incr('spawner.failure.error')
|
||||
e.reason = 'error'
|
||||
try:
|
||||
yield self.stop()
|
||||
@@ -457,11 +459,13 @@ class User(HasTraits):
|
||||
)
|
||||
)
|
||||
e.reason = 'timeout'
|
||||
self.settings['statsd'].incr('spawner.failure.http_timeout')
|
||||
else:
|
||||
e.reason = 'error'
|
||||
self.log.error("Unhandled error waiting for {user}'s server to show up at {url}: {error}".format(
|
||||
user=self.name, url=server.url, error=e,
|
||||
))
|
||||
self.settings['statsd'].incr('spawner.failure.http_error')
|
||||
try:
|
||||
yield self.stop()
|
||||
except Exception:
|
||||
|
@@ -298,17 +298,3 @@ def url_path_join(*pieces):
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def default_server_name(user):
|
||||
"""Return the default name for a new server for a given user.
|
||||
|
||||
Will be the first available integer string, e.g. '1' or '2'.
|
||||
"""
|
||||
existing_names = set(user.spawners)
|
||||
# if there are 5 servers, count from 1 to 6
|
||||
for n in range(1, len(existing_names) + 2):
|
||||
name = str(n)
|
||||
if name not in existing_names:
|
||||
return name
|
||||
raise RuntimeError("It should be impossible to get here")
|
||||
|
||||
|
@@ -4,5 +4,5 @@ tornado>=4.1
|
||||
jinja2
|
||||
pamela
|
||||
python-oauth2>=1.0
|
||||
SQLAlchemy>=1.0
|
||||
SQLAlchemy>=1.1
|
||||
requests
|
||||
|
@@ -22,6 +22,11 @@
|
||||
{{message_html | safe}}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if extra_error_html %}
|
||||
<p>
|
||||
{{extra_error_html | safe}}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock error_detail %}
|
||||
</div>
|
||||
|
||||
|
@@ -8,7 +8,7 @@
|
||||
{% block login %}
|
||||
<div id="login-main" class="container">
|
||||
{% if custom_html %}
|
||||
{{ custom_html }}
|
||||
{{ custom_html | safe }}
|
||||
{% elif login_service %}
|
||||
<div class="service-login">
|
||||
<a role="button" class='btn btn-jupyter btn-lg' href='{{authenticator_login_url}}'>
|
||||
|
Reference in New Issue
Block a user