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
|
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)
|
||||||
|
|
||||||
|
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:
|
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)
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user