mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-07 01:54:09 +00:00
445 lines
13 KiB
Python
445 lines
13 KiB
Python
"""A service is a process that talks to JupyterHub.
|
|
|
|
Types of services:
|
|
Managed:
|
|
- managed by JupyterHub (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,
|
|
'api_token': 'super-secret',
|
|
}
|
|
|
|
A hub-managed service with no URL::
|
|
|
|
{
|
|
'name': 'cull-idle',
|
|
'command': ['python', '/path/to/cull-idle']
|
|
'admin': True,
|
|
}
|
|
|
|
"""
|
|
import asyncio
|
|
import copy
|
|
import os
|
|
import pipes
|
|
import shutil
|
|
from subprocess import Popen
|
|
|
|
from traitlets import (
|
|
Any,
|
|
Bool,
|
|
Dict,
|
|
HasTraits,
|
|
Instance,
|
|
List,
|
|
Unicode,
|
|
default,
|
|
validate,
|
|
)
|
|
from traitlets.config import LoggingConfigurable
|
|
|
|
from .. import orm
|
|
from ..objects import Server
|
|
from ..spawner import LocalProcessSpawner, set_user_setuid
|
|
from ..traitlets import Command
|
|
from ..utils import url_path_join
|
|
|
|
|
|
class _MockUser(HasTraits):
|
|
name = Unicode()
|
|
server = Instance(orm.Server, allow_none=True)
|
|
state = Dict()
|
|
service = Instance(__name__ + '.Service')
|
|
host = Unicode()
|
|
|
|
@property
|
|
def url(self):
|
|
if not self.server:
|
|
return ''
|
|
if self.host:
|
|
return self.host + self.server.base_url
|
|
else:
|
|
return self.server.base_url
|
|
|
|
@property
|
|
def base_url(self):
|
|
if not self.server:
|
|
return ''
|
|
return self.server.base_url
|
|
|
|
|
|
# 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()
|
|
cmd = Command(minlen=0)
|
|
_service_name = Unicode()
|
|
|
|
@default("oauth_access_scopes")
|
|
def _default_oauth_access_scopes(self):
|
|
return [
|
|
"access:services",
|
|
f"access:services!service={self._service_name}",
|
|
]
|
|
|
|
def make_preexec_fn(self, name):
|
|
if not name:
|
|
# no setuid if no name
|
|
return
|
|
return set_user_setuid(name, chdir=False)
|
|
|
|
def user_env(self, env):
|
|
if not self.user.name:
|
|
return env
|
|
else:
|
|
return super().user_env(env)
|
|
|
|
def start(self):
|
|
"""Start the process"""
|
|
env = self.get_env()
|
|
# no activity url for services
|
|
env.pop('JUPYTERHUB_ACTIVITY_URL', None)
|
|
if os.name == 'nt':
|
|
env['SYSTEMROOT'] = os.environ['SYSTEMROOT']
|
|
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 or None,
|
|
)
|
|
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
|
|
- oauth_no_confirm: bool(False)
|
|
Whether this service should be allowed to complete oauth
|
|
with logged-in users without prompting for confirmation.
|
|
|
|
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
|
|
- environment: dict
|
|
Additional environment variables for the service.
|
|
- 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
|
|
"""
|
|
).tag(input=True)
|
|
admin = Bool(False, help="Does the service need admin-access to the Hub API?").tag(
|
|
input=True
|
|
)
|
|
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.
|
|
"""
|
|
).tag(input=True)
|
|
|
|
oauth_roles = List(
|
|
help="""OAuth allowed roles.
|
|
|
|
DEPRECATED in 2.4: use oauth_client_allowed_scopes
|
|
"""
|
|
).tag(input=True)
|
|
|
|
oauth_client_allowed_scopes = List(
|
|
help="""OAuth allowed scopes.
|
|
|
|
This sets the maximum and default scopes
|
|
assigned to oauth tokens issued for this service
|
|
(i.e. tokens stored in browsers after authenticating with the server),
|
|
defining what actions the service can take on behalf of logged-in users.
|
|
|
|
Default is an empty list, meaning minimal permissions to identify users,
|
|
no actions can be taken on their behalf.
|
|
"""
|
|
).tag(input=True)
|
|
|
|
api_token = Unicode(
|
|
help="""The API token to use for the service.
|
|
|
|
If unspecified, an API token will be generated for managed services.
|
|
"""
|
|
).tag(input=True)
|
|
|
|
info = Dict(
|
|
help="""Provide a place to include miscellaneous information about the service,
|
|
provided through the configuration
|
|
"""
|
|
).tag(input=True)
|
|
|
|
display = Bool(
|
|
True, help="""Whether to list the service on the JupyterHub UI"""
|
|
).tag(input=True)
|
|
|
|
oauth_no_confirm = Bool(
|
|
False,
|
|
help="""Skip OAuth confirmation when users access this service.
|
|
|
|
By default, when users authenticate with a service using JupyterHub,
|
|
they are prompted to confirm that they want to grant that service
|
|
access to their credentials.
|
|
Setting oauth_no_confirm=True skips the confirmation web page for this service.
|
|
Skipping the confirmation page is useful for admin-managed services that are considered part of the Hub
|
|
and shouldn't need extra prompts for login.
|
|
|
|
.. versionadded: 1.1
|
|
""",
|
|
).tag(input=True)
|
|
|
|
# Managed service API:
|
|
spawner = Any()
|
|
|
|
@property
|
|
def managed(self):
|
|
"""Am I managed by the Hub?"""
|
|
return bool(self.command)
|
|
|
|
@property
|
|
def kind(self):
|
|
"""The name of the kind of service as a string
|
|
|
|
- 'managed' for managed services
|
|
- 'external' for external services
|
|
"""
|
|
return 'managed' if self.managed else 'external'
|
|
|
|
command = Command(minlen=0, help="Command to spawn this service, if managed.").tag(
|
|
input=True
|
|
)
|
|
cwd = Unicode(help="""The working directory in which to run the service.""").tag(
|
|
input=True
|
|
)
|
|
environment = Dict(
|
|
help="""Environment variables to pass to the service.
|
|
Only used if the Hub is spawning the service.
|
|
"""
|
|
).tag(input=True)
|
|
user = Unicode(
|
|
"",
|
|
help="""The user to become when launching the service.
|
|
|
|
If unspecified, run the service as the same user as the Hub.
|
|
""",
|
|
).tag(input=True)
|
|
|
|
domain = Unicode()
|
|
host = Unicode()
|
|
hub = Any()
|
|
app = Any()
|
|
proc = Any()
|
|
|
|
# handles on globals:
|
|
proxy = Any()
|
|
base_url = Unicode()
|
|
db = Any()
|
|
orm = Any()
|
|
roles = Any()
|
|
cookie_options = Dict()
|
|
|
|
oauth_provider = Any()
|
|
|
|
oauth_client_id = Unicode(
|
|
help="""OAuth client ID for this service.
|
|
|
|
You shouldn't generally need to change this.
|
|
Default: `service-<name>`
|
|
"""
|
|
).tag(input=True)
|
|
|
|
@default('oauth_client_id')
|
|
def _default_client_id(self):
|
|
return 'service-%s' % self.name
|
|
|
|
@validate("oauth_client_id")
|
|
def _validate_client_id(self, proposal):
|
|
if not proposal.value.startswith("service-"):
|
|
raise ValueError(
|
|
f"service {self.name} has oauth_client_id='{proposal.value}'."
|
|
" Service oauth client ids must start with 'service-'"
|
|
)
|
|
return proposal.value
|
|
|
|
oauth_redirect_uri = Unicode(
|
|
help="""OAuth redirect URI for this service.
|
|
|
|
You shouldn't generally need to change this.
|
|
Default: `/services/:name/oauth_callback`
|
|
"""
|
|
).tag(input=True)
|
|
|
|
@default('oauth_redirect_uri')
|
|
def _default_redirect_uri(self):
|
|
if self.server is None:
|
|
return ''
|
|
return self.host + url_path_join(self.prefix, 'oauth_callback')
|
|
|
|
@property
|
|
def oauth_available(self):
|
|
"""Is OAuth available for this client?
|
|
|
|
Returns True if a server is defined or oauth_redirect_uri is specified manually
|
|
"""
|
|
return bool(self.server is not None or self.oauth_redirect_uri)
|
|
|
|
@property
|
|
def oauth_client(self):
|
|
return self.orm.oauth_client
|
|
|
|
@property
|
|
def server(self):
|
|
if self.orm.server:
|
|
return Server.from_orm(self.orm.server)
|
|
else:
|
|
return None
|
|
|
|
@property
|
|
def prefix(self):
|
|
return url_path_join(self.base_url, 'services', self.name + '/')
|
|
|
|
@property
|
|
def proxy_spec(self):
|
|
if not self.server:
|
|
return ''
|
|
if self.domain:
|
|
return self.domain + self.server.base_url
|
|
else:
|
|
return self.server.base_url
|
|
|
|
def __repr__(self):
|
|
return "<{cls}(name={name}{managed})>".format(
|
|
cls=self.__class__.__name__,
|
|
name=self.name,
|
|
managed=' managed' if self.managed else '',
|
|
)
|
|
|
|
async 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
|
|
if self.url:
|
|
env['JUPYTERHUB_SERVICE_URL'] = self.url
|
|
env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url
|
|
|
|
hub = self.hub
|
|
if self.hub.ip in ('', '0.0.0.0', '::'):
|
|
# if the Hub is listening on all interfaces,
|
|
# tell services to connect via localhost
|
|
# since they are always local subprocesses
|
|
hub = copy.deepcopy(self.hub)
|
|
hub.connect_url = ''
|
|
hub.connect_ip = '127.0.0.1'
|
|
|
|
self.spawner = _ServiceSpawner(
|
|
cmd=self.command,
|
|
environment=env,
|
|
api_token=self.api_token,
|
|
oauth_client_id=self.oauth_client_id,
|
|
_service_name=self.name,
|
|
cookie_options=self.cookie_options,
|
|
cwd=self.cwd,
|
|
hub=self.hub,
|
|
user=_MockUser(
|
|
name=self.user, service=self, server=self.orm.server, host=self.host
|
|
),
|
|
internal_ssl=self.app.internal_ssl,
|
|
internal_certs_location=self.app.internal_certs_location,
|
|
internal_trust_bundles=self.app.internal_trust_bundles,
|
|
)
|
|
if self.spawner.internal_ssl:
|
|
self.spawner.cert_paths = await self.spawner.create_certs()
|
|
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
|
|
)
|
|
# schedule start
|
|
asyncio.ensure_future(self.start())
|
|
|
|
async def stop(self):
|
|
"""Stop a managed service"""
|
|
self.log.debug("Stopping service %s", self.name)
|
|
if not self.managed:
|
|
raise RuntimeError("Cannot stop unmanaged service %s" % self)
|
|
if self.spawner:
|
|
if self.orm.server:
|
|
self.db.delete(self.orm.server)
|
|
self.db.commit()
|
|
self.spawner.stop_polling()
|
|
return await self.spawner.stop()
|