Compare commits

...

28 Commits

Author SHA1 Message Date
Min RK
30d4b2cef4 0.8.0rc1 2017-09-19 19:07:34 +02:00
Carol Willing
34e4719893 Merge pull request #1434 from Analect/rest-api-named-server
Add handling for POST/DELETE of named-servers in hub API introduced in 0.8x
2017-09-19 06:17:05 -07:00
analect
c6ac9e1d15 Add handling for POST/DELETE of named-servers introduced in 0.8x 2017-09-19 13:20:15 +01:00
Min RK
70b8876239 Merge pull request #1413 from yuvipanda/memory-float
Allow non integral memory byte specifications
2017-09-18 10:50:56 +02:00
Min RK
5e34f4481a refer to self.UNIT_SUFFIXES 2017-09-18 10:10:20 +02:00
Min RK
eae5594698 byte specifications always return integers 2017-09-18 10:09:14 +02:00
Carol Willing
f02022a00c Merge pull request #1428 from minrk/default-server-name
allow default (empty) server name with named servers
2017-09-17 20:01:31 -07:00
Min RK
f964013516 exercise default server handler with named servers enabled 2017-09-17 11:55:50 +02:00
Min RK
5f7ffaf1f6 allow default (empty) server name with named servers
remove generated names behavior because it doesn't work
2017-09-17 11:47:17 +02:00
Carol Willing
0e7ccb7520 Merge pull request #1422 from minrk/lowercase-timeouts
lowercase LocalProcessSpawner timeouts
2017-09-15 08:11:15 -07:00
Min RK
c9db504a49 Merge pull request #1424 from phill84/bugfix/control-panel-button-height
wrap control panel button in a span
2017-09-15 06:56:41 -07:00
Jiening Wen
716677393e wrap control panel button in a span
make sure the same style is applied to all buttons in header-container
2017-09-15 15:29:38 +02:00
Min RK
ba8484f161 lowercase LocalProcessSpawner timeouts
traitlets doesn't like uppercase configurables
2017-09-15 12:07:03 +02:00
Yuvi Panda
ceec84dbb4 Merge pull request #1417 from minrk/test-delete
test restoring and deleting spawners while the Hub is down
2017-09-14 12:54:38 -07:00
Yuvi Panda
f2a83ec846 Merge pull request #1418 from minrk/oauth-state-boogaloo
Fixes (and tests!) for oauth state handling
2017-09-14 12:43:39 -07:00
Carol Willing
7deea6083a Merge pull request #1416 from minrk/traitlets-log
avoid error if another traitlets Application is initialized
2017-09-14 10:50:52 -07:00
Min RK
a169ff3548 test oauth redirects
include coverage of state handling
2017-09-14 16:06:57 +02:00
Min RK
f84a88da21 fix oauth state redirect
check for HubOAuth, not HubOAuthenticated
2017-09-14 16:06:36 +02:00
Min RK
eecec7183e fix clearing of oauth state cookie
missing path arg
2017-09-14 16:01:34 +02:00
Min RK
f11705ee26 delete service.server from db when they stop
same ondelete='SET NULL' as on spawner.server
2017-09-14 13:30:38 +02:00
Min RK
78ac5abf23 test restoring and deleting spawners while the Hub is down
- set ONDELETE='set null' on spawner->server relation (fixes error when deleting servers that stopped)
- set `spawner.server = None`, which is not triggered when deleting orm_spawner.server
2017-09-14 13:16:29 +02:00
Min RK
2beeaa0932 avoid error if another traitlets Application is initialized
encountered when doing db debugging in IPython
2017-09-14 11:37:34 +02:00
yuvipanda
90cb8423bc Allow non integral memory byte specifications 2017-09-12 16:19:10 -07:00
Min RK
3b07bd286b Merge pull request #1408 from DeepHorizons/update_service_doc
Updated the reference flask service example to include token auth
2017-09-12 23:49:55 +02:00
Joshua Milas
73564b97ea Updated the whoami-flask example 2017-09-11 12:16:17 -04:00
Joshua Milas
65cad5efad Updated the reference flask example to include token auth 2017-09-11 00:09:57 -04:00
Carol Willing
52eb627cd6 Merge pull request #1407 from willingc/spawn-hooks
Add pre/post spawn hooks to docs
2017-09-08 13:01:56 -07:00
Carol Willing
506e568a9a Add pre/post spawn hooks to docs 2017-09-08 13:00:14 -07:00
24 changed files with 277 additions and 66 deletions

View File

@@ -203,6 +203,43 @@ paths:
description: The user's notebook server has stopped description: The user's notebook server has stopped
'202': '202':
description: The user's notebook server has not yet stopped as it is taking a while to stop 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: /users/{name}/admin-access:
post: post:
summary: Grant admin access to this user's notebook server summary: Grant admin access to this user's notebook server

View File

@@ -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, Because the username is passed from the Authenticator to the Spawner,
a custom Authenticator and Spawner are often used together. a custom Authenticator and Spawner are often used together.
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). 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 [OAuth]: https://en.wikipedia.org/wiki/OAuth
[GitHub OAuth]: https://developer.github.com/v3/oauth/ [GitHub OAuth]: https://developer.github.com/v3/oauth/
[OAuthenticator]: https://github.com/jupyterhub/oauthenticator [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

View File

@@ -200,7 +200,9 @@ or via the `JUPYTERHUB_API_TOKEN` environment variable.
Most of the logic for authentication implementation is found in the 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) [`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 - None, if no user could be identified, or
- a dict of the following form: - a dict of the following form:
@@ -252,8 +254,11 @@ def authenticated(f):
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): def decorated(*args, **kwargs):
cookie = request.cookies.get(auth.cookie_name) cookie = request.cookies.get(auth.cookie_name)
token = request.headers.get(auth.auth_header_name)
if cookie: if cookie:
user = auth.user_for_cookie(cookie) user = auth.user_for_cookie(cookie)
elif token:
user = auth.user_for_token(token)
else: else:
user = None user = None
if user: if user:

View File

@@ -28,8 +28,11 @@ def authenticated(f):
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): def decorated(*args, **kwargs):
cookie = request.cookies.get(auth.cookie_name) cookie = request.cookies.get(auth.cookie_name)
token = request.headers.get(auth.auth_header_name)
if cookie: if cookie:
user = auth.user_for_cookie(cookie) user = auth.user_for_cookie(cookie)
elif token:
user = auth.user_for_token(token)
else: else:
user = None user = None
if user: if user:

View File

@@ -7,7 +7,7 @@ version_info = (
0, 0,
8, 8,
0, 0,
'b5', 'rc1',
) )
__version__ = '.'.join(map(str, version_info)) __version__ = '.'.join(map(str, version_info))

View File

@@ -12,9 +12,16 @@ config = context.config
# Interpret the config file for Python logging. # Interpret the config file for Python logging.
# This line sets up loggers basically. # This line sets up loggers basically.
if 'jupyterhub' in sys.modules: if 'jupyterhub' in sys.modules:
from traitlets.config import MultipleInstanceError
from jupyterhub.app import JupyterHub from jupyterhub.app import JupyterHub
app = None
if JupyterHub.initialized(): if JupyterHub.initialized():
try:
app = JupyterHub.instance() app = JupyterHub.instance()
except MultipleInstanceError:
# could have been another Application
pass
if app is not None:
alembic_logger = logging.getLogger('alembic') alembic_logger = logging.getLogger('alembic')
alembic_logger.propagate = True alembic_logger.propagate = True
alembic_logger.parent = app.log alembic_logger.parent = app.log

View File

@@ -114,7 +114,7 @@ class APIHandler(BaseHandler):
if spawner.pending: if spawner.pending:
s['pending'] = spawner.pending s['pending'] = spawner.pending
if spawner.server: if spawner.server:
s['url'] = user.url + name + '/' s['url'] = url_path_join(user.url, name, '/')
return model return model
def group_model(self, group): def group_model(self, group):

View File

@@ -185,8 +185,6 @@ class UserServerAPIHandler(APIHandler):
user = self.find_user(name) user = self.find_user(name)
if server_name and not self.allow_named_servers: if server_name and 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 pending = spawner.pending
if pending == 'spawn': if pending == 'spawn':

View File

@@ -1219,7 +1219,7 @@ class JupyterHub(Application):
status = yield spawner.poll() status = yield spawner.poll()
except Exception: except Exception:
self.log.exception("Failed to poll spawner for %s, assuming the spawner is not running.", 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 status = -1
if status is None: if status is None:
@@ -1230,11 +1230,13 @@ class JupyterHub(Application):
# user not running. This is expected if server is None, # user not running. This is expected if server is None,
# but indicates the user's server died while the Hub wasn't running # but indicates the user's server died while the Hub wasn't running
# if spawner.server is defined. # 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: 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) db.delete(spawner.orm_spawner.server)
spawner.server = None
else:
self.log.debug("%s not running", spawner._log_name)
db.commit() db.commit()
user_summaries.append(_user_summary(user)) user_summaries.append(_user_summary(user))

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 default_server_name, url_path_join from ..utils import 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)
@@ -380,8 +380,6 @@ class BaseHandler(RequestHandler):
self.extra_error_html = self.spawn_home_error self.extra_error_html = self.spawn_home_error
user_server_name = user.name user_server_name = user.name
if self.allow_named_servers and not server_name:
server_name = default_server_name(user)
if server_name: if server_name:
user_server_name = '%s:%s' % (user.name, server_name) user_server_name = '%s:%s' % (user.name, server_name)

View File

@@ -177,7 +177,7 @@ class Spawner(Base):
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE')) 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) server = relationship(Server)
state = Column(JSONDict) state = Column(JSONDict)
@@ -213,7 +213,7 @@ class Service(Base):
api_tokens = relationship("APIToken", backref="service") api_tokens = relationship("APIToken", backref="service")
# service-specific interface # 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) server = relationship(Server, primaryjoin=_server_id == Server.id)
pid = Column(Integer) pid = Column(Integer)

View File

@@ -657,12 +657,11 @@ class HubAuthenticated(object):
def get_login_url(self): def get_login_url(self):
"""Return the Hub's login URL""" """Return the Hub's login URL"""
login_url = self.hub_auth.login_url login_url = self.hub_auth.login_url
app_log.debug("Redirecting to login url: %s", login_url) if isinstance(self.hub_auth, HubOAuth):
if isinstance(self.hub_auth, HubOAuthenticated):
# add state argument to OAuth url # add state argument to OAuth url
state = self.hub_auth.set_state_cookie(self, next_url=self.request.uri) state = self.hub_auth.set_state_cookie(self, next_url=self.request.uri)
return url_concat(login_url, {'state': state}) login_url = url_concat(login_url, {'state': state})
else: app_log.debug("Redirecting to login url: %s", login_url)
return login_url return login_url
def check_hub_user(self, model): def check_hub_user(self, model):
@@ -774,7 +773,7 @@ class HubOAuthCallbackHandler(HubOAuthenticated, RequestHandler):
next_url = None next_url = None
if arg_state or cookie_state: if arg_state or cookie_state:
# clear cookie state now that we've consumed it # clear cookie state now that we've consumed it
self.clear_cookie(self.hub_auth.state_cookie_name) self.clear_cookie(self.hub_auth.state_cookie_name, path=self.hub_auth.base_url)
if isinstance(cookie_state, bytes): if isinstance(cookie_state, bytes):
cookie_state = cookie_state.decode('ascii', 'replace') cookie_state = cookie_state.decode('ascii', 'replace')
# check that state matches # check that state matches

View File

@@ -301,5 +301,8 @@ class Service(LoggingConfigurable):
if not self.managed: if not self.managed:
raise RuntimeError("Cannot stop unmanaged service %s" % self) raise RuntimeError("Cannot stop unmanaged service %s" % self)
if self.spawner: if self.spawner:
if self.orm.server:
self.db.delete(self.orm.server)
self.db.commit()
self.spawner.stop_polling() self.spawner.stop_polling()
return self.spawner.stop() return self.spawner.stop()

View File

@@ -144,11 +144,13 @@ page_template = """
{% block header_buttons %} {% block header_buttons %}
{{super()}} {{super()}}
<a href='{{hub_control_panel_url}}' <span>
<a href='{{hub_control_panel_url}}'
class='btn btn-default btn-sm navbar-btn pull-right' class='btn btn-default btn-sm navbar-btn pull-right'
style='margin-right: 4px; margin-left: 2px;' style='margin-right: 4px; margin-left: 2px;'>
> Control Panel
Control Panel</a> </a>
</span>
{% endblock %} {% endblock %}
{% block logo %} {% block logo %}
<img src='{{logo_url}}' alt='Jupyter Notebook'/> <img src='{{logo_url}}' alt='Jupyter Notebook'/>

View File

@@ -839,7 +839,7 @@ class LocalProcessSpawner(Spawner):
This is the default spawner for JupyterHub. This is the default spawner for JupyterHub.
""" """
INTERRUPT_TIMEOUT = Integer(10, interrupt_timeout = Integer(10,
help=""" help="""
Seconds to wait for single-user server process to halt after SIGINT. Seconds to wait for single-user server process to halt after SIGINT.
@@ -847,7 +847,7 @@ class LocalProcessSpawner(Spawner):
""" """
).tag(config=True) ).tag(config=True)
TERM_TIMEOUT = Integer(5, term_timeout = Integer(5,
help=""" help="""
Seconds to wait for single-user server process to halt after SIGTERM. Seconds to wait for single-user server process to halt after SIGTERM.
@@ -855,7 +855,7 @@ class LocalProcessSpawner(Spawner):
""" """
).tag(config=True) ).tag(config=True)
KILL_TIMEOUT = Integer(5, kill_timeout = Integer(5,
help=""" help="""
Seconds to wait for process to halt after SIGKILL before giving up. Seconds to wait for process to halt after SIGKILL before giving up.
@@ -1071,7 +1071,7 @@ class LocalProcessSpawner(Spawner):
return return
self.log.debug("Interrupting %i", self.pid) self.log.debug("Interrupting %i", self.pid)
yield self._signal(signal.SIGINT) 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 # clean shutdown failed, use TERM
status = yield self.poll() status = yield self.poll()
@@ -1079,7 +1079,7 @@ class LocalProcessSpawner(Spawner):
return return
self.log.debug("Terminating %i", self.pid) self.log.debug("Terminating %i", self.pid)
yield self._signal(signal.SIGTERM) 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 # TERM failed, use KILL
status = yield self.poll() status = yield self.poll()
@@ -1087,7 +1087,7 @@ class LocalProcessSpawner(Spawner):
return return
self.log.debug("Killing %i", self.pid) self.log.debug("Killing %i", self.pid)
yield self._signal(signal.SIGKILL) 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() status = yield self.poll()
if status is None: if status is None:

View File

@@ -8,9 +8,11 @@ from subprocess import check_output, Popen, PIPE
from tempfile import NamedTemporaryFile, TemporaryDirectory from tempfile import NamedTemporaryFile, TemporaryDirectory
from unittest.mock import patch from unittest.mock import patch
from tornado import gen
import pytest import pytest
from .mocking import MockHub from .mocking import MockHub
from .test_api import add_user
from .. import orm from .. import orm
from ..app import COOKIE_SECRET_BYTES from ..app import COOKIE_SECRET_BYTES
@@ -161,3 +163,57 @@ def test_load_groups():
assert gold is not None assert gold is not None
assert sorted([ u.name for u in gold.users ]) == sorted(to_load['gold']) 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)) == []

View File

@@ -46,4 +46,3 @@ def test_upgrade_entrypoint(tmpdir):
# run tokenapp again, it should work # run tokenapp again, it should work
tokenapp.start() tokenapp.start()

View File

@@ -17,6 +17,57 @@ def named_servers(app):
app.tornado_application.settings[key] = app.tornado_settings[key] = False 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 @pytest.mark.gen_test
def test_create_named_server(app, named_servers): def test_create_named_server(app, named_servers):
username = 'walnut' username = 'walnut'
@@ -49,13 +100,13 @@ def test_create_named_server(app, named_servers):
'kind': 'user', 'kind': 'user',
'admin': False, 'admin': False,
'pending': None, 'pending': None,
'server': None, 'server': user.url,
'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 ['', servername]
}, },
} }
@@ -86,13 +137,13 @@ def test_delete_named_server(app, named_servers):
'kind': 'user', 'kind': 'user',
'admin': False, 'admin': False,
'pending': None, 'pending': None,
'server': None, 'server': user.url,
'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'] for name in ['']
}, },
} }

View File

@@ -16,6 +16,7 @@ import requests_mock
from tornado.ioloop import IOLoop from tornado.ioloop import IOLoop
from tornado.httpserver import HTTPServer from tornado.httpserver import HTTPServer
from tornado.web import RequestHandler, Application, authenticated, HTTPError from tornado.web import RequestHandler, Application, authenticated, HTTPError
from tornado.httputil import url_concat
from ..services.auth import _ExpiringDict, HubAuth, HubAuthenticated from ..services.auth import _ExpiringDict, HubAuth, HubAuthenticated
from ..utils import url_path_join from ..utils import url_path_join
@@ -316,7 +317,8 @@ def test_hubauth_service_token(app, mockservice_url):
@pytest.mark.gen_test @pytest.mark.gen_test
def test_oauth_service(app, mockservice_url): 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 # 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()
@@ -326,6 +328,14 @@ def test_oauth_service(app, mockservice_url):
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)
r.raise_for_status() 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 # second request should be authenticated
r = yield s_get(url, allow_redirects=False) r = yield s_get(url, allow_redirects=False)
r.raise_for_status() r.raise_for_status()
@@ -340,7 +350,7 @@ def test_oauth_service(app, mockservice_url):
# token-authenticated request to HubOAuth # token-authenticated request to HubOAuth
token = app.users[name].new_api_token() token = app.users[name].new_api_token()
# token in ?token parameter # 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() r.raise_for_status()
reply = r.json() reply = r.json()
assert reply['name'] == name assert reply['name'] == name
@@ -349,11 +359,12 @@ def test_oauth_service(app, mockservice_url):
assert len(r.cookies) != 0 assert len(r.cookies) != 0
# ensure cookie works in future requests # ensure cookie works in future requests
r = yield async_requests.get( r = yield async_requests.get(
public_url(app, mockservice_url) + 'owhoami/', url,
cookies=r.cookies, cookies=r.cookies,
allow_redirects=False, allow_redirects=False,
) )
r.raise_for_status() r.raise_for_status()
assert r.url == url
reply = r.json() reply = r.json()
assert reply['name'] == name assert reply['name'] == name

View File

@@ -51,9 +51,9 @@ def new_spawner(db, **kwargs):
kwargs.setdefault('notebook_dir', os.getcwd()) kwargs.setdefault('notebook_dir', os.getcwd())
kwargs.setdefault('default_url', '/user/{username}/lab') kwargs.setdefault('default_url', '/user/{username}/lab')
kwargs.setdefault('oauth_client_id', 'mock-client-id') kwargs.setdefault('oauth_client_id', 'mock-client-id')
kwargs.setdefault('INTERRUPT_TIMEOUT', 1) kwargs.setdefault('interrupt_timeout', 1)
kwargs.setdefault('TERM_TIMEOUT', 1) kwargs.setdefault('term_timeout', 1)
kwargs.setdefault('KILL_TIMEOUT', 1) kwargs.setdefault('kill_timeout', 1)
kwargs.setdefault('poll_interval', 1) kwargs.setdefault('poll_interval', 1)
return user._new_spawner('', spawner_class=LocalProcessSpawner, **kwargs) return user._new_spawner('', spawner_class=LocalProcessSpawner, **kwargs)
@@ -299,7 +299,7 @@ def test_spawner_reuse_api_token(db, app):
@pytest.mark.gen_test @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 """Token provided by spawner is not in the db
Insert token into db as a user-provided token. 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 @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""" """Tokens are revoked when a Spawner gets another user's token"""
# we need two users for this one # we need two users for this one
user = add_user(app.db, app, name='antimone') user = add_user(app.db, app, name='antimone')
@@ -346,3 +346,37 @@ def test_spawner_bad_api_token(db, app):
yield user.spawn() yield user.spawn()
assert orm.APIToken.find(app.db, other_token) is None assert orm.APIToken.find(app.db, other_token) is None
assert other_user.api_tokens == [] 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

View File

@@ -34,18 +34,27 @@ def test_memoryspec():
c = C() c = C()
c.mem = 1024 c.mem = 1024
assert isinstance(c.mem, int)
assert c.mem == 1024 assert c.mem == 1024
c.mem = '1024K' c.mem = '1024K'
assert isinstance(c.mem, int)
assert c.mem == 1024 * 1024 assert c.mem == 1024 * 1024
c.mem = '1024M' c.mem = '1024M'
assert isinstance(c.mem, int)
assert c.mem == 1024 * 1024 * 1024 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' c.mem = '1024G'
assert isinstance(c.mem, int)
assert c.mem == 1024 * 1024 * 1024 * 1024 assert c.mem == 1024 * 1024 * 1024 * 1024
c.mem = '1024T' c.mem = '1024T'
assert isinstance(c.mem, int)
assert c.mem == 1024 * 1024 * 1024 * 1024 * 1024 assert c.mem == 1024 * 1024 * 1024 * 1024 * 1024
with pytest.raises(TraitError): with pytest.raises(TraitError):

View File

@@ -48,7 +48,7 @@ class ByteSpecification(Integer):
'K': 1024, 'K': 1024,
'M': 1024 * 1024, 'M': 1024 * 1024,
'G': 1024 * 1024 * 1024, 'G': 1024 * 1024 * 1024,
'T': 1024 * 1024 * 1024 * 1024 'T': 1024 * 1024 * 1024 * 1024,
} }
# Default to allowing None as a value # 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 If it has one of the suffixes, it is converted into the appropriate
pure byte value. pure byte value.
""" """
if isinstance(value, int): if isinstance(value, (int, float)):
return value return int(value)
num = value[:-1]
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] 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)) 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: else:
return int(num) * ByteSpecification.UNIT_SUFFIXES[suffix] return int(float(num) * self.UNIT_SUFFIXES[suffix])

View File

@@ -12,7 +12,7 @@ from tornado import gen
from tornado.log import app_log from tornado.log import app_log
from traitlets import HasTraits, Any, Dict, default 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 . import orm
from ._version import _check_version, __version__ from ._version import _check_version, __version__

View File

@@ -298,17 +298,3 @@ def url_path_join(*pieces):
return result 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")