From ec52f67cea91f6bfe2d27064fdc79cb6ea9ce51d Mon Sep 17 00:00:00 2001 From: MinRK Date: Mon, 18 Aug 2014 15:20:43 -0700 Subject: [PATCH] store the multi-user state in a database via SQLAlchemy ORM --- multiuser/app.py | 147 ++++++++++++++++++++------- multiuser/db.py | 213 ++++++++++++++++++++++++++++++++++++++++ multiuser/handlers.py | 178 +++++++++++++++++++++++++-------- multiuser/singleuser.py | 38 +++---- multiuser/spawner.py | 185 ++++++++++++++++++++++++++++++++++ multiuser/user.py | 180 --------------------------------- multiuser/utils.py | 5 +- 7 files changed, 671 insertions(+), 275 deletions(-) create mode 100644 multiuser/db.py create mode 100644 multiuser/spawner.py delete mode 100644 multiuser/user.py diff --git a/multiuser/app.py b/multiuser/app.py index d2c29548..387d746c 100644 --- a/multiuser/app.py +++ b/multiuser/app.py @@ -9,10 +9,12 @@ import tornado.options from tornado import web from IPython.utils.traitlets import ( - Unicode, Integer, Dict, TraitError, List, + Unicode, Integer, Dict, TraitError, List, Instance, Bool, Bytes, Any, + DottedObjectName, ) from IPython.config import Application -from IPython.html import utils +from IPython.html.utils import url_path_join +from IPython.utils.importstring import import_item here = os.path.dirname(__file__) @@ -24,35 +26,79 @@ from .handlers import ( UserHandler, ) -from .user import UserManager +from . import db -class MultiUserAuthenticationApp(Application): +# from .user import UserManager + +class MultiUserApp(Application): + + ip = Unicode('localhost', config=True, + help="The public facing ip of the proxy" + ) port = Integer(8000, config=True, help="The public facing port of the proxy" ) + base_url = Unicode('/', config=True, + help="The base URL of the entire application" + ) + proxy_auth_token = Unicode(config=True, + help="The Proxy Auth token" + ) + def _proxy_auth_token_default(self): + return db.new_token() + + proxy_api_ip = Unicode('localhost', config=True, + help="The ip for the proxy API handlers" + ) proxy_api_port = Integer(config=True, help="The port for the proxy API handlers" ) def _proxy_api_port_default(self): return self.port + 1 - multiuser_port = Integer(8081, config=True, + hub_port = Integer(8081, config=True, help="The port for this process" ) - - multiuser_prefix = Unicode('/multiuser/', config=True, - help="The prefix for the multi-user server. Must not be '/'" + hub_ip = Unicode('localhost', config=True, + help="The ip for this process" ) - def _multiuser_prefix_changed(self, name, old, new): + + hub_prefix = Unicode('/hub/', config=True, + help="The prefix for the hub server. Must not be '/'" + ) + def _hub_prefix_default(self): + return url_path_join(self.base_url, '/hub/') + + def _hub_prefix_changed(self, name, old, new): if new == '/': - raise TraitError("'/' is not a valid multi-user prefix") + raise TraitError("'/' is not a valid hub prefix") newnew = new if not new.startswith('/'): newnew = '/' + new if not newnew.endswith('/'): newnew = newnew + '/' + if not newnew.startswith(self.base_url): + newnew = url_path_join(self.base_url, newnew) if newnew != new: - self.multiuser_prefix = newnew + self.hub_prefix = newnew + + cookie_secret = Bytes(config=True) + def _cookie_secret_default(self): + return b'secret!' + + # spawning subprocesses + spawner_class = DottedObjectName("multiuser.spawner.ProcessSpawner") + def _spawner_class_changed(self, name, old, new): + self.spawner = import_item(new) + + spawner = Any() + def _spawner_default(self): + return import_item(self.spawner_class) + + + db_url = Unicode('sqlite:///:memory:', config=True) + debug_db = Bool(False) + db = Any() tornado_settings = Dict(config=True) @@ -62,7 +108,7 @@ class MultiUserAuthenticationApp(Application): """add a url prefix to handlers""" for i, tup in enumerate(handlers): lis = list(tup) - lis[0] = utils.url_path_join(prefix, tup[0]) + lis[0] = url_path_join(prefix, tup[0]) handlers[i] = tuple(lis) return handlers @@ -73,38 +119,62 @@ class MultiUserAuthenticationApp(Application): (r"/logout", LogoutHandler), (r"/api/authorizations/([^/]+)", AuthorizationsHandler), ] - self.handlers = self.add_url_prefix(self.multiuser_prefix, handlers) + self.handlers = self.add_url_prefix(self.hub_prefix, handlers) self.handlers.extend([ (r"/user/([^/]+)/?.*", UserHandler), - (r"/", web.RedirectHandler, {"url" : self.multiuser_prefix}), + (r"/", web.RedirectHandler, {"url" : self.hub_prefix}), ]) - def init_user_manager(self): - self.user_manager = UserManager(proxy_port=self.proxy_api_port) + def init_db(self): + # TODO: load state from db for resume + self.db = db.new_session(self.db_url, echo=self.debug_db) + + def init_hub(self): + self.hub = db.Hub( + server=db.Server( + ip=self.hub_ip, + port=self.hub_port, + base_url=self.hub_prefix, + cookie_secret=self.cookie_secret, + cookie_name='jupyter-hub-token', + ) + ) + self.db.add(self.hub) + self.db.commit() def init_proxy(self): + self.proxy = db.Proxy( + public_server=db.Server( + ip=self.ip, + port=self.port, + ), + api_server=db.Server( + ip=self.proxy_api_ip, + port=self.proxy_api_port, + base_url='/api/routes/' + ), + auth_token = db.new_token(), + ) + self.db.add(self.proxy) + self.db.commit() + + def start_proxy(self): env = os.environ.copy() - env['CONFIGPROXY_AUTH_TOKEN'] = self.user_manager.proxy_auth_token + env['CONFIGPROXY_AUTH_TOKEN'] = self.proxy.auth_token self.proxy = Popen(["node", os.path.join(here, 'js', 'main.js'), - '--port', str(self.port), - '--api-port', str(self.proxy_api_port), - '--upstream-port', str(self.multiuser_port), + '--port', str(self.proxy.public_server.port), + '--api-port', str(self.proxy.api_server.port), + '--upstream-port', str(self.hub.server.port), ], env=env) def init_tornado_settings(self): - base_url = self.multiuser_prefix + base_url = self.base_url self.tornado_settings.update( + db=self.db, + hub=self.hub, base_url=base_url, - user_manager=self.user_manager, - cookie_secret='super secret', - cookie_name='multiusertest', - multiuser_prefix=base_url, - multiuser_api_url=utils.url_path_join( - 'http://localhost:%i' % self.multiuser_port, - base_url, - 'api', - ), - login_url=utils.url_path_join(base_url, 'login'), + cookie_secret=self.cookie_secret, + login_url=url_path_join(self.hub.server.base_url, 'login'), template_path=os.path.join(here, 'templates'), ) @@ -112,25 +182,28 @@ class MultiUserAuthenticationApp(Application): self.tornado_application = web.Application(self.handlers, **self.tornado_settings) def initialize(self, *args, **kwargs): - super(MultiUserAuthenticationApp, self).initialize(*args, **kwargs) - self.init_user_manager() + super(MultiUserApp, self).initialize(*args, **kwargs) + self.init_db() + self.init_hub() self.init_proxy() self.init_handlers() self.init_tornado_settings() self.init_tornado_application() def start(self): + self.start_proxy() http_server = tornado.httpserver.HTTPServer(self.tornado_application) - http_server.listen(self.multiuser_port) + http_server.listen(self.hub_port) try: tornado.ioloop.IOLoop.instance().start() except KeyboardInterrupt: print("\nInterrupted") finally: - self.proxy.terminate() - self.user_manager.cleanup() + pass + # self.proxy.terminate() + # self.user_manager.cleanup() -main = MultiUserAuthenticationApp.launch_instance +main = MultiUserApp.launch_instance if __name__ == "__main__": main() diff --git a/multiuser/db.py b/multiuser/db.py new file mode 100644 index 00000000..a539aba1 --- /dev/null +++ b/multiuser/db.py @@ -0,0 +1,213 @@ +"""sqlalchemy ORM tools for the user database""" + +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +import json +import uuid + +from sqlalchemy.types import TypeDecorator, VARCHAR +from sqlalchemy import ( + Column, Integer, String, ForeignKey, Unicode, Binary, +) +from sqlalchemy.ext.declarative import declarative_base, declared_attr + +from sqlalchemy.orm import sessionmaker, relationship, backref + +from sqlalchemy import create_engine + +from IPython.utils.py3compat import str_to_unicode + +from .utils import random_port + + +def new_token(*args, **kwargs): + """generator for new random tokens + + For now, just UUIDs. + """ + return str_to_unicode(uuid.uuid4().hex) + + +class JSONDict(TypeDecorator): + """Represents an immutable structure as a json-encoded string. + + Usage:: + + JSONEncodedDict(255) + + """ + + impl = VARCHAR + + def process_bind_param(self, value, dialect): + if value is not None: + value = json.dumps(value) + + return value + + def process_result_value(self, value, dialect): + if value is not None: + value = json.loads(value) + return value + + +Base = declarative_base() + + +class Server(Base): + __tablename__ = 'servers' + id = Column(Integer, primary_key=True) + proto = Column(Unicode, default=u'http') + ip = Column(Unicode, default=u'localhost') + port = Column(Integer, default=random_port) + cookie_secret = Column(Binary) + cookie_name = Column(Unicode) + base_url = Column(Unicode, default=u'/') + + def __repr__(self): + return "" % (self.ip, self.port) + + @property + def url(self): + return "{proto}://{ip}:{port}{url}".format( + proto=self.proto, + ip=self.ip, + port=self.port, + url=self.base_url, + ) + + +class Proxy(Base): + """A configurable-http-proxy instance""" + __tablename__ = 'proxies' + id = Column(Integer, primary_key=True) + auth_token = Column(Unicode, default=new_token) + _public_server_id = Column(Integer, ForeignKey('servers.id')) + public_server = relationship(Server, primaryjoin=_public_server_id == Server.id) + _api_server_id = Column(Integer, ForeignKey('servers.id')) + api_server = relationship(Server, primaryjoin=_api_server_id == Server.id) + + def __repr__(self): + if self.public_server: + return "<%s %s:%s>" % ( + self.__class__.__name__, self.public_server.ip, self.public_server.port, + ) + else: + return "<%s [unconfigured]>" % self.__class__.__name__ + + +class Hub(Base): + """Bring it all together at the hub""" + __tablename__ = 'hubs' + id = Column(Integer, primary_key=True) + _server_id = Column(Integer, ForeignKey('servers.id')) + server = relationship(Server, primaryjoin=_server_id == Server.id) + api_url = Column(Unicode, default=u'/hub/api/') + + @property + def api_host_url(self): + """return the full API url (with proto://host...)""" + return "{proto}://{ip}:{port}{url}".format( + proto=self.server.proto, + ip=self.server.ip, + port=self.server.port, + url=self.api_url, + ) + + def __repr__(self): + if self.server: + return "<%s %s:%s>" % ( + self.__class__.__name__, self.server.ip, self.server.port, + ) + else: + return "<%s [unconfigured]>" % self.__class__.__name__ + + +class User(Base): + """The User table""" + __tablename__ = 'users' + id = Column(Integer, primary_key=True) + name = Column(Unicode) + _server_id = Column(Integer, ForeignKey('servers.id')) + server = relationship(Server, primaryjoin=_server_id == Server.id) + + api_tokens = relationship("APIToken", backref="user") + cookie_tokens = relationship("CookieToken", backref="user") + state = Column(JSONDict) + + def __repr__(self): + if self.server: + return "<{cls}({name}@{ip}:{port})>".format( + cls=self.__class__.__name__, + name=self.name, + ip=self.server.ip, + port=self.server.port, + ) + else: + return "<{cls}({name} [unconfigured])>".format( + cls=self.__class__.__name__, + name=self.name, + ) + + def _new_token(self, cls): + assert self.id is not None + return cls(token=new_token(), user_id=self.id) + + def new_api_token(self): + """Return a new API token""" + return self._new_token(APIToken) + + def new_cookie_token(self): + """Return a new cookie token""" + return self._new_token(CookieToken) + + +class Token(object): + """Mixin for token tables, since we have two""" + token = Column(String, primary_key=True) + @declared_attr + def user_id(cls): + return Column(Integer, ForeignKey('users.id')) + + def __repr__(self): + return "<{cls}('{t}', user='{u}')>".format( + cls=self.__class__.__name__, + t=self.token, + u=self.user.name, + ) + + +class APIToken(Token, Base): + """An API token""" + __tablename__ = 'api_tokens' + + +class CookieToken(Token, Base): + """A cookie token""" + __tablename__ = 'cookie_tokens' + + +def new_session(url="sqlite:///:memory:", **kwargs): + """Create a new session at url""" + engine = create_engine(url, **kwargs) + Session = sessionmaker(bind=engine) + session = Session() + Base.metadata.create_all(engine) + return session + + +if __name__ == '__main__': + engine = create_engine('sqlite:///:memory:', echo=True) + Session = sessionmaker(bind=engine) + session = Session() + Base.metadata.create_all(engine) + + hub = Hub() + session.add(hub) + session.commit() + + minrk = User(name="minrk") + session.add(minrk) + session.commit() + \ No newline at end of file diff --git a/multiuser/handlers.py b/multiuser/handlers.py index b08c2a34..94ab9806 100644 --- a/multiuser/handlers.py +++ b/multiuser/handlers.py @@ -1,35 +1,61 @@ -"""HTTP Handlers for the multi-user server""" +"""HTTP Handlers for the hub server""" -# Copyright (c) IPython Development Team. +# Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import json import re +import requests + from tornado.log import app_log from tornado.escape import url_escape from tornado.httputil import url_concat from tornado.web import RequestHandler from tornado import web +from IPython.html.utils import url_path_join + +from . import db +from .spawner import PopenSpawner +from .utils import random_port, wait_for_server + class BaseHandler(RequestHandler): """Base Handler class with access to common methods and properties.""" + + @property + def log(self): + """I can't seem to avoid typing self.log""" + return app_log + + @property + def config(self): + return self.settings.get('config', None) + + @property + def db(self): + return self.settings['db'] + + @property + def hub(self): + return self.settings['hub'] + @property def cookie_name(self): return self.settings.get('cookie_name', 'cookie') @property - def multiuser_url(self): - return self.settings.get('multiuser_url', '') + def hub_url(self): + return self.settings.get('hub_url', '') @property - def multiuser_prefix(self): - return self.settings.get('multiuser_prefix', '/multiuser/') + def hub_prefix(self): + return self.settings.get('hub_prefix', '/hub/') def get_current_user(self): if 'get_current_user' in self.settings: - return self.settings['get_current_user'] + return self.settings['get_current_user']() token = self.get_cookie(self.cookie_name, '') if token: @@ -41,21 +67,27 @@ class BaseHandler(RequestHandler): def base_url(self): return self.settings.setdefault('base_url', '/') - @property - def user_manager(self): - return self.settings['user_manager'] - def clear_login_cookie(self): self.clear_cookie(self.cookie_name) - def spawn_single_user(self, user): - session = self.user_manager.get_session(user, - cookie_secret=self.settings['cookie_secret'], - multiuser_api_url=self.settings['multiuser_api_url'], - multiuser_prefix=self.settings['multiuser_prefix'], - ) - self.user_manager.spawn(user) - return session + @property + def spawner_class(self): + return self.settings.get('spawner_class', PopenSpawner) + + # def spawn_single_user(self, user): + # spawner = self.spawner_class( + # user=user, + # cookie_secret=self.settings['cookie_secret'], + # hub_api_url=self.settings['hub_api_url'], + # hub_prefix=self.settings['hub_prefix'], + # ) + # session = self.user_manager.get_session(user, + # cookie_secret=self.settings['cookie_secret'], + # hub_api_url=self.settings['hub_api_url'], + # hub_prefix=self.settings['hub_prefix'], + # ) + # self.user_manager.spawn(user) + # return session class RootHandler(BaseHandler): @@ -72,7 +104,7 @@ class UserHandler(BaseHandler): """ @web.authenticated def get(self, user): - self.log.debug("multi-user at single-user url: %s", user) + self.log.debug("hub at single-user url: %s", user) if self.get_current_user() == user: self.spawn_single_user(user) self.redirect('') @@ -105,19 +137,83 @@ class LoginHandler(BaseHandler): else: user = self.get_argument('user', default='') self._render(user=user) - + + def notify_proxy(self, user): + proxy = self.db.query(db.Proxy).first() + r = requests.post( + url_path_join( + proxy.api_server.url, + user.server.base_url, + ), + data=json.dumps(dict( + target=user.server.url, + user=user.name, + )), + headers={'Authorization': "token %s" % proxy.auth_token}, + ) + wait_for_server(user.server.ip, user.server.port) + r.raise_for_status() + + def spawn_single_user(self, name): + user = db.User(name=name, + server=db.Server( + cookie_name='%s-%s' % (self.hub.server.cookie_name, name), + cookie_secret=self.hub.server.cookie_secret, + base_url=url_path_join(self.base_url, 'user', name), + ), + ) + self.db.add(user) + self.db.commit() + + api_token = user.new_api_token() + self.db.add(api_token) + self.db.commit() + + spawner = self.spawner_class( + config=self.config, + user=user, + hub=self.hub, + api_token=api_token.token, + ) + spawner.start() + + # store state + user.state = spawner.get_state() + self.db.commit() + + self.notify_proxy(user) + return user + def post(self): - user = self.get_argument('user', default='') + name = self.get_argument('user', default='') pwd = self.get_argument('password', default=u'') - next_url = self.get_argument('next', default='') or '/user/%s/' % user - if user and pwd == 'password': - if user not in self.user_manager.users: - session = self.spawn_single_user(user) - else: - session = self.user_manager.users[user] - cookie_token = session.cookie_token - self.set_cookie(session.cookie_name, cookie_token, path=session.url_prefix) - self.set_cookie(self.cookie_name, cookie_token, path=self.base_url) + next_url = self.get_argument('next', default='') or '/user/%s/' % name + if name and pwd == 'password': + import IPython + # IPython.embed() + user = self.db.query(db.User).filter(db.User.name == name).first() + if user is None: + user = self.spawn_single_user(name) + + # create and set a new cookie token for the single-user server + cookie_token = user.new_cookie_token() + self.db.add(cookie_token) + self.db.commit() + + self.set_cookie( + user.server.cookie_name, + cookie_token.token, + path=user.server.base_url, + ) + + # create and set a new cookie token for the hub + cookie_token = user.new_cookie_token() + self.db.add(cookie_token) + self.db.commit() + self.set_cookie( + self.hub.server.cookie_name, + cookie_token.token, + path=self.hub.server.base_url) else: self._render( message={'error': 'Invalid username or password'}, @@ -142,10 +238,10 @@ def token_authorized(method): if not match: raise web.HTTPError(403) token = match.group(1) - session = self.user_manager.user_for_api_token(token) - if session is None: + db_token = self.db.query(db.APIToken).filter(db.APIToken.token == token).first() + self.log.info("Token: %s: %s", token, db_token) + if db_token is None: raise web.HTTPError(403) - self.request_session = session return method(self, *args, **kwargs) check_token.__name__ = method.__name__ check_token.__doc__ = method.__doc__ @@ -155,12 +251,14 @@ def token_authorized(method): class AuthorizationsHandler(BaseHandler): @token_authorized def get(self, token): - session = self.user_manager.user_for_cookie_token(token) - if session is None: - app_log.debug('cookie tokens: %r', - { user:s.cookie_token for user,s in self.user_manager.users.items() } - ) + db_token = self.db.query(db.CookieToken).filter(db.CookieToken.token == token).first() + import IPython + IPython.embed() + if db_token is None: + # app_log.debug('cookie tokens: %r', + # { user:s.cookie_token for user,s in self.user_manager.users.items() } + # ) raise web.HTTPError(404) self.write(json.dumps({ - 'user' : session.user, + 'user' : db_token.user.name, })) diff --git a/multiuser/singleuser.py b/multiuser/singleuser.py index 43759653..54540c72 100644 --- a/multiuser/singleuser.py +++ b/multiuser/singleuser.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -"""Extend regular notebook server to be aware of multi-user things.""" +"""Extend regular notebook server to be aware of multiuser things.""" import os @@ -24,12 +24,12 @@ def verify_token(self, token): # we've seen this token before, don't ask upstream again return token_cache[token] - multiuser_api_url = self.settings['multiuser_api_url'] - multiuser_api_key = self.settings['multiuser_api_key'] + hub_api_url = self.settings['hub_api_url'] + hub_api_key = self.settings['hub_api_key'] r = requests.get(utils.url_path_join( - multiuser_api_url, "authorizations", token, + hub_api_url, "authorizations", token, ), - headers = {'Authorization' : 'token %s' % multiuser_api_key} + headers = {'Authorization' : 'token %s' % hub_api_key} ) if r.status_code == 404: data = {'user' : ''} @@ -53,48 +53,52 @@ def get_current_user(self): if user == my_user: return user else: - raise web.HTTPError(403, "User %s does not have access to %s" % (user, my_user)) + # import IPython + # IPython.embed() + return None + # imoprt + # raise web.HTTPError(403, "User %s does not have access to %s" % (user, my_user)) else: self.log.debug("No token cookie") return None -# register new multi-user related command-line aliases +# register new hub related command-line aliases aliases = NotebookApp.aliases.get_default_value() aliases.update({ 'user' : 'SingleUserNotebookApp.user', 'cookie-name': 'SingleUserNotebookApp.cookie_name', - 'multiuser-prefix': 'SingleUserNotebookApp.multiuser_prefix', - 'multiuser-api-url': 'SingleUserNotebookApp.multiuser_api_url', + 'hub-prefix': 'SingleUserNotebookApp.hub_prefix', + 'hub-api-url': 'SingleUserNotebookApp.hub_api_url', 'base-url': 'SingleUserNotebookApp.base_url', }) class SingleUserNotebookApp(NotebookApp): - """A Subclass of the regular NotebookApp that is aware of the parent multi-user context.""" + """A Subclass of the regular NotebookApp that is aware of the parent multiuser context.""" user = Unicode(config=True) cookie_name = Unicode(config=True) - multiuser_prefix = Unicode(config=True) - multiuser_api_url = Unicode(config=True) + hub_prefix = Unicode(config=True) + hub_api_url = Unicode(config=True) aliases = aliases browser = False def init_webapp(self): - # monkeypatch authentication to use the multi-user + # monkeypatch authentication to use the hub from IPython.html.base.handlers import AuthenticatedHandler AuthenticatedHandler.verify_token = verify_token AuthenticatedHandler.get_current_user = get_current_user - # load the multi-user related settings into the tornado settings dict + # load the hub related settings into the tornado settings dict env = os.environ s = self.webapp_settings s['token_cache'] = {} s['user'] = self.user - s['multiuser_api_key'] = env.get('IPY_API_TOKEN', '') + s['hub_api_key'] = env.get('IPY_API_TOKEN', '') s['cookie_secret'] = env.get('IPY_COOKIE_SECRET', '') s['cookie_name'] = self.cookie_name - s['login_url'] = utils.url_path_join(self.multiuser_prefix, 'login') - s['multiuser_api_url'] = self.multiuser_api_url + s['login_url'] = utils.url_path_join(self.hub_prefix, 'login') + s['hub_api_url'] = self.hub_api_url super(SingleUserNotebookApp, self).init_webapp() diff --git a/multiuser/spawner.py b/multiuser/spawner.py new file mode 100644 index 00000000..7735d38e --- /dev/null +++ b/multiuser/spawner.py @@ -0,0 +1,185 @@ +"""Class for spawning single-user notebook servers.""" + +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +import errno +import os +import signal +import sys +import time +from subprocess import Popen + +from tornado import gen + +from IPython.config import LoggingConfigurable +from IPython.utils.traitlets import ( + Any, Dict, Instance, Integer, List, Unicode, +) + + +from .utils import random_port, wait_for_server + + +class Spawner(LoggingConfigurable): + """Base class for spawning single-user notebook servers. + + Subclass this, and override the following methods: + + - load_state + - get_state + - start + - stop + - poll + """ + + user = Any() + hub = Any() + api_token = Unicode() + + env_prefix = Unicode('IPY_') + def _env_key(self, d, key, value): + d['%s%s' % (self.env_prefix, key)] = value + + env = Dict() + def _env_default(self): + env = os.environ.copy() + self._env_key(env, 'COOKIE_SECRET', self.user.server.cookie_secret) + self._env_key(env, 'API_TOKEN', self.api_token) + return env + + @classmethod + def fromJSON(cls, state, **kwargs): + """Create a new instance, and load its JSON state + + state will be a dict, loaded from JSON in the database. + """ + inst = cls(**kwargs) + inst.load_state(state) + return inst + + def load_state(self, state): + """load state from the database + + This is the extensible part of state + + Override in a subclass if there is state to load. + + See Also + -------- + + get_state + """ + pass + + def get_state(self): + """store the state necessary for load_state + + A black box of extra state for custom spawners + + Returns + ------- + + state: dict + a JSONable dict of state + """ + return {} + + def get_args(self): + """Return the arguments to be passed after self.cmd""" + return [ + '--user=%s' % self.user.name, + '--port=%i' % self.user.server.port, + '--cookie-name=%s' % self.user.server.cookie_name, + '--base-url=%s' % self.user.server.base_url, + + '--hub-prefix=%s' % self.hub.server.base_url, + '--hub-api-url=%s' % self.hub.api_host_url, + ] + + def start(self): + raise NotImplementedError("Override in subclass") + + def stop(self): + raise NotImplementedError("Override in subclass") + + def poll(self): + raise NotImplementedError("Override in subclass") + + +class PopenSpawner(Spawner): + """A Spawner that just uses Popen to start local processes.""" + cmd = List(Unicode, config=True, + help="""The command used for starting notebooks.""" + ) + def _cmd_default(self): + # should have sudo -u self.user + return [sys.executable, '-m', 'multiuser.singleuser'] + + proc = Instance(Popen) + pid = Integer() + + def load_state(self, state): + self.pid = state['pid'] + + def get_state(self): + return dict(pid=self.pid) + + def start(self): + self.user.server.port = random_port() + cmd = self.cmd + self.get_args() + + self.log.info("Spawning %r", cmd) + self.proc = Popen(cmd, env=self.env, + # don't forward signals + preexec_fn=os.setpgrp, + ) + self.pid = self.proc.pid + + def poll(self): + # if we started the process, poll with Popen + if self.proc is not None: + return self.proc.poll() + + # if we resumed from stored state, + # we don't have the Popen handle anymore + + # this doesn't work on Windows. + # multi-user doesn't support Windows. + try: + os.kill(self.pid, 0) + except OSError as e: + if e.errno == errno.ESRCH: + # no such process, return exitcode == 0, since we don't know + return 0 + else: + # None indicates the process is running + return None + + def _wait_for_death(self, timeout=10): + """wait for the process to die, up to timeout seconds""" + for i in range(int(timeout * 10)): + if self.poll() is None: + break + else: + time.sleep(0.1) + + def stop(self, now=False): + """stop the subprocess""" + if not now: + # double-sigint to request clean shutdown + os.kill(self.pid, signal.SIGINT) + os.kill(self.pid, signal.SIGINT) + self._wait_for_death(10) + + # clean shutdown failed, use TERM + if self.poll() is None: + os.kill(self.pid, signal.SIGTERM) + self._wait_for_death(5) + + # TERM failed, use KILL + if self.poll() is None: + os.kill(self.pid, signal.SIGKILL) + self._wait_for_death(5) + + # it all failed, zombie process diff --git a/multiuser/user.py b/multiuser/user.py deleted file mode 100644 index c5a49172..00000000 --- a/multiuser/user.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Classes for managing users.""" - -import json -import os -import signal -import sys -import time -import uuid -from subprocess import Popen - -import requests - -from tornado.log import app_log - -from IPython.utils.traitlets import Any, Unicode, Integer, Dict -from IPython.config import LoggingConfigurable - -from .utils import random_port, wait_for_server - -class UserSession(LoggingConfigurable): - env_prefix = Unicode('IPY_') - process = Any() - port = Integer() - user = Unicode() - cookie_secret = Unicode() - cookie_name = Unicode() - def _cookie_name_default(self): - return 'ipy-multiuser-%s' % self.user - - multiuser_prefix = Unicode() - multiuser_api_url = Unicode() - - url_prefix = Unicode() - def _url_prefix_default(self): - return '/user/%s/' % self.user - - api_token = Unicode() - def _api_token_default(self): - return str(uuid.uuid4()) - - cookie_token = Unicode() - def _cookie_token_default(self): - return str(uuid.uuid4()) - - def _env_key(self, d, key, value): - d['%s%s' % (self.env_prefix, key)] = value - - env = Dict() - def _env_default(self): - env = os.environ.copy() - self._env_key(env, 'COOKIE_SECRET', self.cookie_secret) - self._env_key(env, 'API_TOKEN', self.api_token) - return env - - @property - def auth_data(self): - return dict( - user=self.user, - ) - - def start(self): - assert self.process is None or self.process.poll() is not None - cmd = [sys.executable, '-m', 'multiuser.singleuser', - '--user=%s' % self.user, '--port=%i' % self.port, - '--cookie-name=%s' % self.cookie_name, - '--multiuser-prefix=%s' % self.multiuser_prefix, - '--multiuser-api-url=%s' % self.multiuser_api_url, - '--base-url=%s' % self.url_prefix, - ] - app_log.info("Spawning: %s" % cmd) - self.process = Popen(cmd, env=self.env, - # don't forward signals: - preexec_fn=os.setpgrp, - ) - - def running(self): - if self.process is None: - return False - if self.process.poll() is not None: - self.process = None - return False - return True - - def request_stop(self): - if self.running(): - self.process.send_signal(signal.SIGINT) - time.sleep(0.1) - if self.running(): - self.process.send_signal(signal.SIGINT) - - def stop(self): - for i in range(100): - if self.running(): - time.sleep(0.1) - else: - break - if self.running(): - self.process.terminate() - - -class UserManager(LoggingConfigurable): - - users = Dict() - routes_t = Unicode('http://{ip}:{port}/api/routes{uri}') - single_user_t = Unicode('http://{ip}:{port}') - - single_user_ip = Unicode('localhost') - proxy_ip = Unicode('localhost') - proxy_port = Integer(8001) - - proxy_auth_token = Unicode() - def _proxy_auth_token_default(self): - return str(uuid.uuid4()) - - def get_session(self, user, **kwargs): - if user not in self.users: - kwargs['user'] = user - self.users[user] = UserSession(**kwargs) - return self.users[user] - - def spawn(self, user): - session = self.get_session(user) - if session.running(): - app_log.warn("User session %s already running", user) - return - session.port = port = random_port() - session.start() - - r = requests.post( - self.routes_t.format( - ip=self.proxy_ip, - port=self.proxy_port, - uri=session.url_prefix, - ), - data=json.dumps(dict( - target=self.single_user_t.format( - ip=self.single_user_ip, - port=port - ), - user=user, - )), - headers={'Authorization': "token %s" % self.proxy_auth_token}, - ) - wait_for_server(self.single_user_ip, port) - r.raise_for_status() - - def user_for_api_token(self, token): - """Get the user session object for a given API token""" - for session in self.users.values(): - if session.api_token == token: - return session - - def user_for_cookie_token(self, token): - """Get the user session object for a given cookie token""" - for session in self.users.values(): - if session.cookie_token == token: - return session - - def shutdown(self, user): - assert user in self.users - session = self.users.pop(user) - session.stop() - r = requests.delete(self.routes_url, - data=json.dumps(user=user, port=session.port), - ) - r.raise_for_status() - - def cleanup(self): - sessions = list(self.users.values()) - self.users = {} - for session in sessions: - self.log.info("Cleaning up %s's server" % session.user) - session.request_stop() - for i in range(100): - if any([ session.running() for session in sessions ]): - time.sleep(0.1) - else: - break - for session in sessions: - session.stop() diff --git a/multiuser/utils.py b/multiuser/utils.py index b54ddfd9..a589e5e4 100644 --- a/multiuser/utils.py +++ b/multiuser/utils.py @@ -1,11 +1,13 @@ """Miscellaneous utilities""" -# Copyright (c) IPython Development Team. +# Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import socket import time + + def random_port(): """get a single random port""" sock = socket.socket() @@ -14,6 +16,7 @@ def random_port(): sock.close() return port + def wait_for_server(ip, port, timeout=10): """wait for a server to show up at ip:port""" tic = time.time()