From a1c787ba5f0d9e3c9e552d05faa204e3d6ba624c Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 26 Aug 2016 17:26:28 +0200 Subject: [PATCH] basic implementation of managed services - managed services are automatically restarted - proxied services not there yet --- jupyterhub/app.py | 62 +++++++- jupyterhub/services/service.py | 264 +++++++++++++++++++++++++++++++++ jupyterhub/spawner.py | 2 +- 3 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 jupyterhub/services/service.py diff --git a/jupyterhub/app.py b/jupyterhub/app.py index f42ff689..41886ab0 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -44,6 +44,7 @@ here = os.path.dirname(__file__) import jupyterhub from . import handlers, apihandlers from .handlers.static import CacheControlStaticFilesHandler, LogoHandler +from .services.service import Service from . import dbutil, orm from .user import User, UserDict @@ -408,6 +409,29 @@ class JupyterHub(Application): Allows ahead-of-time generation of API tokens for use by externally managed services. """ ).tag(config=True) + + services = List(Dict(), + help="""List of service specification dictionaries. + + A service + + For instance:: + + services = [ + { + 'name': 'cull_idle', + 'command': ['/path/to/cull_idle_servers.py'], + }, + { + 'name': 'formgrader', + 'url': 'http://127.0.0.1:1234', + 'token': 'super-secret', + 'env': + } + ] + """ + ).tag(config=True) + _service_map = Dict() authenticator_class = Type(PAMAuthenticator, Authenticator, help="""Class for authenticating users. @@ -933,6 +957,35 @@ class JupyterHub(Application): """Load predefined API tokens (for services) into database""" self._add_tokens(self.service_tokens, kind='service') self._add_tokens(self.api_tokens, kind='user') + + def init_services(self): + self._service_map = {} + for spec in self.services: + if 'name' not in spec: + raise ValueError('service spec must have a name: %r' % spec) + name = spec['name'] + # get/create orm + orm_service = orm.Service.find(self.db, name=name) + if orm_service is None: + # not found, create a new one + orm_service = orm.Service(name=name) + self.db.add(orm_service) + orm_service.admin = spec.get('admin', False) + self.db.commit() + service = Service( + proxy=self.proxy, hub=self.hub, base_url=self.base_url, + db=self.db, orm=orm_service, + parent=self, + hub_api_url=self.hub.api_url, + **spec) + self._service_map[name] = service + if service.managed: + if not service.api_token: + # generate new token + service.api_token = service.orm.new_api_token() + else: + # ensure provided token is registered + self.service_tokens[service.api_token] = service.name @gen.coroutine def init_spawners(self): @@ -1197,6 +1250,7 @@ class JupyterHub(Application): self.init_proxy() yield self.init_users() self.init_groups() + self.init_services() self.init_api_tokens() self.init_tornado_settings() yield self.init_spawners() @@ -1333,7 +1387,13 @@ class JupyterHub(Application): except Exception as e: self.log.critical("Failed to start proxy", exc_info=True) self.exit(1) - return + + for service_name, service in self._service_map.items(): + try: + yield service.start() + except Exception as e: + self.log.critical("Failed to start service %s", service_name, exc_info=True) + self.exit(1) loop.add_callback(self.proxy.add_all_users, self.users) diff --git a/jupyterhub/services/service.py b/jupyterhub/services/service.py new file mode 100644 index 00000000..b4cbe0fa --- /dev/null +++ b/jupyterhub/services/service.py @@ -0,0 +1,264 @@ +"""A service is a process that talks to JupyterHub + +Cases: + +Managed: + - managed by JuyterHub (always subprocess, no custom Spawners) + - always a long-running process + - managed services are restarted automatically if they exit unexpectedly +Unmanaged: + - managed by external service (docker, systemd, etc.) + - do not need to be long-running processes, or processes at all + + +URL: needs a route added to the proxy. + - Public route will always be /services/service-name + - url specified in config + - if port is 0, Hub will select a port + +API access: + - admin: tokens will have admin-access to the API + - not admin: tokens will only have non-admin access + (not much they can do other than defer to Hub for auth) + +An externally managed service running on a URL:: + + { + 'name': 'my-service', + 'url': 'https://host:8888', + 'admin': True, + 'token': 'super-secret', + } + +A hub-managed service with no URL: + + { + 'name': 'cull-idle', + 'command': ['python', '/path/to/cull-idle'] + 'admin': True, + } +""" + +from getpass import getuser +import pipes +import shutil +from subprocess import Popen +from urllib.parse import urlparse + +from tornado import gen + +from traitlets import ( + HasTraits, + Any, Bool, Dict, Unicode, Instance, + observe, +) +from traitlets.config import LoggingConfigurable + +from .. import orm +from ..traitlets import Command +from ..spawner import LocalProcessSpawner +from ..utils import url_path_join + +class _MockUser(HasTraits): + name = Unicode() + server = Instance(orm.Server, allow_none=True) + state = Dict() + service = Instance(__module__ + '.Service') + +# We probably shouldn't use a Spawner here, +# but there are too many concepts to share. + +class _ServiceSpawner(LocalProcessSpawner): + """Subclass of LocalProcessSpawner + + Removes notebook-specific-ness from LocalProcessSpawner. + """ + cwd = Unicode() + + def make_preexec_fn(self, name): + if not name or name == getuser(): + # no setuid if no name + return + return super().make_preexec_fn(name) + + @gen.coroutine + def start(self): + """Start the process""" + env = self.get_env() + cmd = self.cmd + + self.log.info("Spawning %s", ' '.join(pipes.quote(s) for s in cmd)) + try: + self.proc = Popen(self.cmd, env=env, + preexec_fn=self.make_preexec_fn(self.user.name), + start_new_session=True, # don't forward signals + cwd=self.cwd, + ) + except PermissionError: + # use which to get abspath + script = shutil.which(cmd[0]) or cmd[0] + self.log.error("Permission denied trying to run %r. Does %s have access to this file?", + script, self.user.name, + ) + raise + + self.pid = self.proc.pid + +class Service(LoggingConfigurable): + """An object wrapping a service specification for Hub API consumers. + + A service has inputs: + + - name: str + the name of the service + - admin: bool(false) + whether the service should have administrative privileges + - url: str (None) + The URL where the service is/should be. + If specified, the service will be added to the proxy at /services/:name + + If a service is to be managed by the Hub, it has a few extra options: + + - command: (str/Popen list) + Command for JupyterHub to spawn the service. + Only use this if the service should be a subprocess. + If command is not specified, it is assumed to be managed + by a + - env: dict + environment variables to add to the current env + - user: str + The name of a system user to become. + If unspecified, run as the same user as the Hub. + """ + + # inputs: + name = Unicode( + help="""The name of the service. + + If the service has an http endpoint, it + """ + ) + admin = Bool(False, + help="Does the service need admin-access to the Hub API?" + ) + url = Unicode( + help="""URL of the service. + + Only specify if the service runs an HTTP(s) endpoint that. + If managed, will be passed as JUPYTERHUB_SERVICE_URL env. + """ + ) + @observe('url') + def _url_changed(self, change): + url = change['new'] + if not url: + self.orm.server = None + else: + if self.orm.server is None: + parsed = urlparse(url) + if parsed.port is not None: + port = parsed.port + elif parsed.scheme == 'http': + port = 80 + elif parsed.scheme == 'https': + port = 443 + server = self.orm.server = orm.Server( + proto=parsed.scheme, + ip=parsed.host, + port=port, + cookie_name='jupyterhub-services', + base_url=self.proxy_path, + ) + self.db.add(server) + self.db.commit() + + api_token = Unicode( + help="""The API token to use for the service. + + If unspecified, an API token will be generated for managed services. + """ + ) + # Managed service API: + + @property + def managed(self): + """Am I managed by the Hub?""" + return bool(self.command) + + command = Command( + help="Command to spawn this service, if managed." + ) + cwd = Unicode( + help="""The working directory in which to run the service.""" + ) + environment = Dict( + help="""Environment variables to pass to the service. + Only used if the Hub is spawning the service. + """ + ) + user = Unicode(getuser(), + help="""The user to become when launching the service. + + If unspecified, run the service as the same user as the Hub. + """ + ) + + # handles on globals: + proxy = Any() + hub = Any() + base_url = Unicode() + + @property + def proxy_path(self): + return url_path_join(self.base_url, 'services', self.name) + + def __repr__(self): + return "<{cls}(name={name}{managed})>".format( + cls=self.__class__.__name__, + name=self.name, + managed=' managed' if self.managed else '', + ) + + @gen.coroutine + def start(self): + """Start a managed service""" + if not self.managed: + raise RuntimeError("Cannot start unmanaged service %s" % self) + self.log.info("Starting service %r: %r", self.name, self.command) + env = {} + env.update(self.environment) + + env['JUPYTERHUB_SERVICE_NAME'] = self.name + env['JUPYTERHUB_API_TOKEN'] = self.api_token + env['JUPYTERHUB_API_URL'] = self.hub_api_url + env['JUPYTERHUB_BASE_URL'] = self.base_url + env['JUPYTERHUB_SERVICE_PATH'] = self.proxy_path + + self.spawner = _ServiceSpawner( + cmd=self.command, + environment=env, + api_token=self.api_token, + cwd=self.cwd, + user=_MockUser( + name=self.user, + service=self, + server=self.orm.server, + ), + ) + yield self.spawner.start() + self.proc = self.spawner.proc + self.spawner.add_poll_callback(self._proc_stopped) + self.spawner.start_polling() + + def _proc_stopped(self): + """Called when the service process unexpectedly exits""" + self.log.error("Service %s exited with status %i", self.name, self.proc.returncode) + self.proc = None + self.start() + + def stop(self): + """Stop a managed service""" + if not self.managed: + raise RuntimeError("Cannot start unmanaged service %s" % self) + self.spawner.stop_polling() + return self.spawner.stop() diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index 775c41be..0ff15d16 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -571,4 +571,4 @@ class LocalProcessSpawner(Spawner): if status is None: # it all failed, zombie process self.log.warning("Process %i never died", self.pid) - +