diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py index 72c21702..fac87bcb 100644 --- a/jupyterhub/apihandlers/base.py +++ b/jupyterhub/apihandlers/base.py @@ -97,9 +97,9 @@ class APIHandler(BaseHandler): 'pending': None, 'last_activity': user.last_activity.isoformat(), } - if user.spawn_pending: + if user.spawners['']._spawn_pending: model['pending'] = 'spawn' - elif user.stop_pending: + elif user.spawners['']._stop_pending: model['pending'] = 'stop' return model diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py index 7a44a12e..14190454 100644 --- a/jupyterhub/apihandlers/users.py +++ b/jupyterhub/apihandlers/users.py @@ -146,11 +146,11 @@ class UserAPIHandler(APIHandler): raise web.HTTPError(404) if user.name == self.get_current_user().name: raise web.HTTPError(400, "Cannot delete yourself!") - if user.stop_pending: + if user.spawner._stop_pending: raise web.HTTPError(400, "%s's server is in the process of stopping, please wait." % name) if user.running: yield self.stop_single_user(user) - if user.stop_pending: + if user.spawner._stop_pending: raise web.HTTPError(400, "%s's server is in the process of stopping, please wait." % name) yield gen.maybe_future(self.authenticator.delete_user(user)) @@ -193,14 +193,14 @@ class UserServerAPIHandler(APIHandler): options = self.get_json_body() yield self.spawn_single_user(user, options=options) - status = 202 if user.spawn_pending else 201 + status = 202 if user.spawner._spawn_pending else 201 self.set_status(status) @gen.coroutine @admin_or_self def delete(self, name): user = self.find_user(name) - if user.stop_pending: + if user.spawner._stop_pending: self.set_status(202) return if not user.running: @@ -210,7 +210,7 @@ class UserServerAPIHandler(APIHandler): if status is not None: raise web.HTTPError(400, "%s's server is not running" % name) yield self.stop_single_user(user) - status = 202 if user.stop_pending else 204 + status = 202 if user.spawner._stop_pending else 204 self.set_status(status) @@ -221,10 +221,11 @@ class UserCreateNamedServerAPIHandler(APIHandler): """ @gen.coroutine @admin_or_self - def post(self, name): + def post(self, name, server_name=''): user = self.find_user(name) if user is None: raise web.HTTPError(404, "No such user %r" % name) + #if user.running: # # include notify, so that a server that died is noticed immediately # state = yield user.spawner.poll_and_notify() @@ -232,8 +233,8 @@ class UserCreateNamedServerAPIHandler(APIHandler): # raise web.HTTPError(400, "%s's server is already running" % name) options = self.get_json_body() - yield self.spawn_single_user(user, options=options) - status = 202 if user.spawn_pending else 201 + yield self.spawn_single_user(user, server_name, options=options) + status = 202 if user.spawner._spawn_pending else 201 self.set_status(status) @@ -248,17 +249,18 @@ class UserDeleteNamedServerAPIHandler(APIHandler): @admin_or_self def delete(self, name, server_name): user = self.find_user(name) - if user.stop_pending: + spawner = user.spawners[server_name] + if spawner._stop_pending: self.set_status(202) return #if not user.running: # raise web.HTTPError(400, "%s's server is not running" % name) # include notify, so that a server that died is noticed immediately - status = yield user.spawner.poll_and_notify() + status = yield spawner.poll_and_notify() if status is not None: raise web.HTTPError(400, "%s's server is not running" % name) - yield self.stop_single_user(user) - status = 202 if user.stop_pending else 204 + yield self.stop_single_user(user, server_name) + status = 202 if spawner._stop_pending else 204 self.set_status(status) class UserAdminAccessAPIHandler(APIHandler): @@ -288,7 +290,7 @@ default_handlers = [ (r"/api/users", UserListAPIHandler), (r"/api/users/([^/]+)", UserAPIHandler), (r"/api/users/([^/]+)/server", UserServerAPIHandler), - (r"/api/users/([^/]+)/servers", UserCreateNamedServerAPIHandler), - (r"/api/users/([^/]+)/servers/([^/]+)", UserDeleteNamedServerAPIHandler), + (r"/api/users/([^/]+)/servers/([^/]*)", UserCreateNamedServerAPIHandler), + (r"/api/users/([^/]+)/servers/([^/]*)", UserDeleteNamedServerAPIHandler), (r"/api/users/([^/]+)/admin-access", UserAdminAccessAPIHandler), ] diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index 7288d2bf..6e48ce80 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -319,12 +319,13 @@ class BaseHandler(RequestHandler): return self.settings.get('spawner_class', LocalProcessSpawner) @gen.coroutine - def spawn_single_user(self, user, options=None): - if user.spawn_pending: + def spawn_single_user(self, user, server_name='', options=None): + if server_name in user.spawners and user.spawners[server_name]._spawn_pending: raise RuntimeError("Spawn already pending for: %s" % user.name) tic = IOLoop.current().time() - f = user.spawn(options) + f = user.spawn(server_name, options) + spawner = user.spawners[server_name] @gen.coroutine def finish_user_spawn(f=None): @@ -340,14 +341,14 @@ class BaseHandler(RequestHandler): self.log.info("User %s server took %.3f seconds to start", user.name, toc-tic) self.statsd.timing('spawner.success', (toc - tic) * 1000) yield self.proxy.add_user(user) - user.spawner.add_poll_callback(self.user_stopped, user) + spawner.add_poll_callback(self.user_stopped, user) try: yield gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), f) except gen.TimeoutError: # waiting_for_response indicates server process has started, # but is yet to become responsive. - if not user.waiting_for_response: + if not spawner._waiting_for_response: # still in Spawner.start, which is taking a long time # we shouldn't poll while spawn is incomplete. self.log.warning("User %s's server is slow to start (timeout=%s)", @@ -387,7 +388,7 @@ class BaseHandler(RequestHandler): @gen.coroutine def stop_single_user(self, user): - if user.stop_pending: + if user.spawner._stop_pending: raise RuntimeError("Stop already pending for: %s" % user.name) tic = IOLoop.current().time() yield self.proxy.delete_user(user) @@ -408,7 +409,7 @@ class BaseHandler(RequestHandler): try: yield gen.with_timeout(timedelta(seconds=self.slow_stop_timeout), f) except gen.TimeoutError: - if user.stop_pending: + if user.spawner._stop_pending: # hit timeout, but stop is still pending self.log.warning("User %s server is slow to stop", user.name) # schedule finish for when the server finishes stopping @@ -535,23 +536,23 @@ class UserSpawnHandler(BaseHandler): """, self.request.full_url(), self.proxy.public_url) # logged in as correct user, spawn the server - if current_user.spawner: - if current_user.spawn_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 + spawner = current_user.spawner + if spawner._spawn_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 - # spawn has supposedly finished, check on the status - status = yield current_user.spawner.poll() - if status is not None: - if current_user.spawner.options_form: - self.redirect(url_concat(url_path_join(self.hub.base_url, 'spawn'), - {'next': self.request.uri})) - return - else: - yield self.spawn_single_user(current_user) + # spawn has supposedly finished, check on the status + status = yield spawner.poll() + if status is not None: + if spawner.options_form: + self.redirect(url_concat(url_path_join(self.hub.base_url, 'spawn'), + {'next': self.request.uri})) + return + else: + yield self.spawn_single_user(current_user) # set login cookie anew self.set_login_cookie(current_user) without_prefix = self.request.uri[len(self.hub.base_url):] diff --git a/jupyterhub/proxy.py b/jupyterhub/proxy.py index 48d4f1b3..868973b8 100644 --- a/jupyterhub/proxy.py +++ b/jupyterhub/proxy.py @@ -140,7 +140,7 @@ class Proxy(LoggingConfigurable): user.name, user.proxy_path, user.server.host, ) - if user.spawn_pending: + if user.spawner._spawn_pending: raise RuntimeError( "User %s's spawn is pending, shouldn't be added to the proxy yet!", user.name) diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index 891c531e..5007cc76 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -45,6 +45,11 @@ class Spawner(LoggingConfigurable): is created for each user. If there are 20 JupyterHub users, there will be 20 instances of the subclass. """ + + # private attributes for tracking status + _spawn_pending = False + _stop_pending = False + _waiting_for_response = False db = Any() user = Any() @@ -500,6 +505,8 @@ class Spawner(LoggingConfigurable): Doesn't expect shell expansion to happen. """ + args = [] + if self.ip: args.append('--ip="%s"' % self.ip) diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index f0cb3fce..feb62d3b 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -409,7 +409,7 @@ def test_spawn(app, io_loop): app_user = get_app_user(app, name) assert app_user.spawner is not None assert app_user.spawner.user_options == options - assert not app_user.spawn_pending + assert not app_user.spawner._spawn_pending status = io_loop.run_sync(app_user.spawner.poll) assert status is None @@ -458,38 +458,38 @@ def test_slow_spawn(app, io_loop, no_patience, request): assert r.status_code == 202 app_user = get_app_user(app, name) assert app_user.spawner is not None - assert app_user.spawn_pending - assert not app_user.stop_pending + assert app_user.spawner._spawn_pending + assert not app_user.spawner._stop_pending @gen.coroutine def wait_spawn(): - while app_user.spawn_pending: + while app_user.spawner._spawn_pending: yield gen.sleep(0.1) io_loop.run_sync(wait_spawn) - assert not app_user.spawn_pending + assert not app_user.spawner._spawn_pending status = io_loop.run_sync(app_user.spawner.poll) assert status is None @gen.coroutine def wait_stop(): - while app_user.stop_pending: + while app_user.pawner._stop_pending: yield gen.sleep(0.1) r = api_request(app, 'users', name, 'server', method='delete') r.raise_for_status() assert r.status_code == 202 assert app_user.spawner is not None - assert app_user.stop_pending + assert app_user.spawner._stop_pending r = api_request(app, 'users', name, 'server', method='delete') r.raise_for_status() assert r.status_code == 202 assert app_user.spawner is not None - assert app_user.stop_pending + assert app_user.spawner._stop_pending io_loop.run_sync(wait_stop) - assert not app_user.stop_pending + assert not app_user.spawner._stop_pending assert app_user.spawner is not None r = api_request(app, 'users', name, 'server', method='delete') assert r.status_code == 400 @@ -506,15 +506,15 @@ def test_never_spawn(app, io_loop, no_patience, request): r = api_request(app, 'users', name, 'server', method='post') app_user = get_app_user(app, name) assert app_user.spawner is not None - assert app_user.spawn_pending + assert app_user.spawner._spawn_pending @gen.coroutine def wait_pending(): - while app_user.spawn_pending: + while app_user.spawner._spawn_pending: yield gen.sleep(0.1) io_loop.run_sync(wait_pending) - assert not app_user.spawn_pending + assert not app_user.spawner._spawn_pending status = io_loop.run_sync(app_user.spawner.poll) assert status is not None diff --git a/jupyterhub/user.py b/jupyterhub/user.py index aa1fa950..53d14120 100644 --- a/jupyterhub/user.py +++ b/jupyterhub/user.py @@ -1,6 +1,7 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. +from collections import defaultdict from datetime import datetime, timedelta from urllib.parse import quote, urlparse @@ -96,13 +97,10 @@ class User(HasTraits): if self.orm_user: id = self.orm_user.id self.orm_user = change['new'].query(orm.User).filter(orm.User.id == id).first() - self.spawner.db = self.db + for spawner in self.spawners.values(): + spawner.db = self.db orm_user = None - spawner = None - spawn_pending = False - stop_pending = False - waiting_for_response = False @property def authenticator(self): @@ -111,7 +109,6 @@ class User(HasTraits): @property def spawner_class(self): return self.settings.get('spawner_class', LocalProcessSpawner) - def __init__(self, orm_user, settings=None, **kwargs): self.orm_user = orm_user @@ -124,7 +121,11 @@ class User(HasTraits): self.base_url = url_path_join( self.settings.get('base_url', '/'), 'user', self.escaped_name) - self.spawner = self.spawner_class( + self.spawners = defaultdict(self._new_spawner) + + def _new_spawner(self): + """Create a new spawner""" + return self.spawner_class( user=self, db=self.db, hub=self.settings.get('hub'), @@ -132,19 +133,14 @@ class User(HasTraits): config=self.settings.get('config'), ) + # singleton property, self.spawner maps onto spawner with empty server_name @property - def get_spawners(self): - return self._instances + def spawner(self): + return self.spawners[''] - @property - def get_spawner(self, server_name): - try: - return self._instances[server_name] - except KeyError as err: - self.log.warning("spawner for server named %s doesn't exist" % server_name) - - def save_spawner(self, server_name): - self._instances[server_name] = self.spawner + @spawner.setter + def spawner(self, spawner): + self.spawners[''] = spawner # pass get/setattr to ORM user @@ -162,16 +158,17 @@ class User(HasTraits): def __repr__(self): return repr(self.orm_user) - - @property # FIX-ME CHECK IF STILL NEEDED + + @property def running(self): """property for whether a user has a running server""" - if self.spawn_pending or self.stop_pending: + spawner = self.spawner + if spawner._spawn_pending or spawner._stop_pending: return False # server is not running if spawn or stop is still pending - if self.server is None: + if spawner.server is None: return False return True - + @property def server(self): if len(self.servers) == 0: @@ -220,7 +217,7 @@ class User(HasTraits): return self.base_url @gen.coroutine - def spawn(self, options=None): + def spawn(self, server_name='', options=None): """Start the user's spawner depending from the value of JupyterHub.allow_named_servers @@ -234,15 +231,10 @@ class User(HasTraits): url of the server will be /user/:name/:server_name """ db = self.db - if self.allow_named_servers: - if options is not None and 'server_name' in options: - server_name = options['server_name'] - else: - server_name = default_server_name(self) - base_url = url_path_join(self.base_url, server_name) - else: - server_name = '' - base_url = self.base_url + 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) orm_server = orm.Server( name=server_name, @@ -255,14 +247,10 @@ class User(HasTraits): server = Server(orm_server=orm_server) - spawner = self.spawner - - # Save spawner's instance inside self._instances - self.save_spawner(server_name) + spawner = self.spawners[server_name] # Passing server, server_name and options to the spawner spawner.server = server - spawner.server_name = server_name spawner.user_options = options or {} # we are starting a new server, make sure it doesn't restore state spawner.clear_state() @@ -294,7 +282,7 @@ class User(HasTraits): if (authenticator): yield gen.maybe_future(authenticator.pre_spawn_start(self, spawner)) - self.spawn_pending = True + spawner._spawn_pending = True # wait for spawner.start to return try: f = spawner.start() @@ -336,10 +324,10 @@ class User(HasTraits): spawner.start_polling() # store state - self.state = spawner.get_state() + self.state[server_name] = spawner.get_state() self.last_activity = datetime.utcnow() db.commit() - self.waiting_for_response = True + spawner._waiting_for_response = True try: yield server.wait_up(http=True, timeout=spawner.http_timeout) except Exception as e: @@ -367,32 +355,30 @@ class User(HasTraits): # raise original TimeoutError raise e finally: - self.waiting_for_response = False - self.spawn_pending = False + spawner._waiting_for_response = False + spawner._spawn_pending = False return self @gen.coroutine - def stop(self): + def stop(self, server_name=''): """Stop the user's spawner and cleanup after it. """ - self.spawn_pending = False - spawner = self.spawner - self.spawner.stop_polling() - self.stop_pending = True + spawner = self.spawners[server_name] + spawner._spawn_pending = False + spawner.stop_polling() + spawner._stop_pending = True try: - api_token = self.spawner.api_token + api_token = spawner.api_token status = yield spawner.poll() if status is None: - yield self.spawner.stop() + yield spawner.stop() spawner.clear_state() self.state = spawner.get_state() self.last_activity = datetime.utcnow() - # Cleanup defunct servers: delete entry and API token for each server - for server in self.servers: - # remove server entry from db - self.db.delete(server) + # remove server entry from db + self.db.delete(spawner.server.orm_server) if not spawner.will_resume: # find and remove the API token if the spawner isn't # going to re-use it next time @@ -401,7 +387,7 @@ class User(HasTraits): self.db.delete(orm_token) self.db.commit() finally: - self.stop_pending = False + spawner._stop_pending = False # trigger post-spawner hook on authenticator auth = spawner.authenticator if auth: