mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-14 21:43:01 +00:00
basic implementation of managed services
- managed services are automatically restarted - proxied services not there yet
This commit is contained in:
@@ -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)
|
||||
|
||||
|
264
jupyterhub/services/service.py
Normal file
264
jupyterhub/services/service.py
Normal 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()
|
@@ -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)
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user