basic implementation of managed services

- managed services are automatically restarted
- proxied services not there yet
This commit is contained in:
Min RK
2016-08-26 17:26:28 +02:00
parent 54c808fe98
commit a1c787ba5f
3 changed files with 326 additions and 2 deletions

View File

@@ -44,6 +44,7 @@ here = os.path.dirname(__file__)
import jupyterhub import jupyterhub
from . import handlers, apihandlers from . import handlers, apihandlers
from .handlers.static import CacheControlStaticFilesHandler, LogoHandler from .handlers.static import CacheControlStaticFilesHandler, LogoHandler
from .services.service import Service
from . import dbutil, orm from . import dbutil, orm
from .user import User, UserDict 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. Allows ahead-of-time generation of API tokens for use by externally managed services.
""" """
).tag(config=True) ).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, authenticator_class = Type(PAMAuthenticator, Authenticator,
help="""Class for authenticating users. help="""Class for authenticating users.
@@ -933,6 +957,35 @@ class JupyterHub(Application):
"""Load predefined API tokens (for services) into database""" """Load predefined API tokens (for services) into database"""
self._add_tokens(self.service_tokens, kind='service') self._add_tokens(self.service_tokens, kind='service')
self._add_tokens(self.api_tokens, kind='user') 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 @gen.coroutine
def init_spawners(self): def init_spawners(self):
@@ -1197,6 +1250,7 @@ class JupyterHub(Application):
self.init_proxy() self.init_proxy()
yield self.init_users() yield self.init_users()
self.init_groups() self.init_groups()
self.init_services()
self.init_api_tokens() self.init_api_tokens()
self.init_tornado_settings() self.init_tornado_settings()
yield self.init_spawners() yield self.init_spawners()
@@ -1333,7 +1387,13 @@ class JupyterHub(Application):
except Exception as e: except Exception as e:
self.log.critical("Failed to start proxy", exc_info=True) self.log.critical("Failed to start proxy", exc_info=True)
self.exit(1) 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) loop.add_callback(self.proxy.add_all_users, self.users)

View File

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

View File

@@ -571,4 +571,4 @@ class LocalProcessSpawner(Spawner):
if status is None: if status is None:
# it all failed, zombie process # it all failed, zombie process
self.log.warning("Process %i never died", self.pid) self.log.warning("Process %i never died", self.pid)