store the multi-user state in a database

via SQLAlchemy ORM
This commit is contained in:
MinRK
2014-08-18 15:20:43 -07:00
parent a2d1481cd5
commit ec52f67cea
7 changed files with 671 additions and 275 deletions

View File

@@ -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()

213
multiuser/db.py Normal file
View File

@@ -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 "<Server(%s:%s)>" % (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()

View File

@@ -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,
}))

View File

@@ -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()

185
multiuser/spawner.py Normal file
View File

@@ -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

View File

@@ -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()

View File

@@ -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()