""" Contains base Spawner class & default implementation """ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import ast import json import os import pipes import shutil import signal import sys import warnings from subprocess import Popen from tempfile import mkdtemp from urllib.parse import urlparse if os.name == 'nt': import psutil from async_generator import aclosing from sqlalchemy import inspect from tornado.ioloop import PeriodicCallback from traitlets import Any from traitlets import Bool from traitlets import default from traitlets import Dict from traitlets import Float from traitlets import Instance from traitlets import Integer from traitlets import List from traitlets import observe from traitlets import Unicode from traitlets import Union from traitlets import validate from traitlets.config import LoggingConfigurable from .objects import Server from .traitlets import ByteSpecification from .traitlets import Callable from .traitlets import Command from .utils import exponential_backoff from .utils import iterate_until from .utils import maybe_future from .utils import random_port from .utils import url_path_join # FIXME: remove when we drop Python 3.5 support def _quote_safe(s): """pass a string that is safe on the command-line traitlets may parse literals on the command-line, e.g. `--ip=123` will be the number 123 instead of the *string* 123. wrap valid literals in repr to ensure they are safe """ try: val = ast.literal_eval(s) except Exception: # not valid, leave it alone return s else: # it's a valid literal, wrap it in repr (usually just quotes, but with proper escapes) # to avoid getting interpreted by traitlets return repr(s) class Spawner(LoggingConfigurable): """Base class for spawning single-user notebook servers. Subclass this, and override the following methods: - load_state - get_state - start - stop - poll As JupyterHub supports multiple users, an instance of the Spawner subclass is created for each user. If there are 20 JupyterHub users, there will be 20 instances of the subclass. """ # private attributes for tracking status _spawn_pending = False _start_pending = False _stop_pending = False _proxy_pending = False _check_pending = False _waiting_for_response = False _jupyterhub_version = None _spawn_future = None @property def _log_name(self): """Return username:servername or username Used in logging for consistency with named servers. """ if self.name: return '%s:%s' % (self.user.name, self.name) else: return self.user.name @property def _failed(self): """Did the last spawn fail?""" return ( not self.active and self._spawn_future and self._spawn_future.done() and self._spawn_future.exception() ) @property def pending(self): """Return the current pending event, if any Return False if nothing is pending. """ if self._spawn_pending: return 'spawn' elif self._stop_pending: return 'stop' elif self._check_pending: return 'check' return None @property def ready(self): """Is this server ready to use? A server is not ready if an event is pending. """ if self.pending: return False if self.server is None: return False return True @property def active(self): """Return True if the server is active. This includes fully running and ready or any pending start/stop event. """ return bool(self.pending or self.ready) # options passed by constructor authenticator = Any() hub = Any() orm_spawner = Any() db = Any() cookie_options = Dict() @observe('orm_spawner') def _orm_spawner_changed(self, change): if change.new and change.new.server: self._server = Server(orm_server=change.new.server) else: self._server = None user = Any() def __init_subclass__(cls, **kwargs): super().__init_subclass__() missing = [] for attr in ('start', 'stop', 'poll'): if getattr(Spawner, attr) is getattr(cls, attr): missing.append(attr) if missing: raise NotImplementedError( "class `{}` needs to redefine the `start`," "`stop` and `poll` methods. `{}` not redefined.".format( cls.__name__, '`, `'.join(missing) ) ) proxy_spec = Unicode() @property def last_activity(self): return self.orm_spawner.last_activity @property def server(self): if hasattr(self, '_server'): return self._server if self.orm_spawner and self.orm_spawner.server: return Server(orm_server=self.orm_spawner.server) @server.setter def server(self, server): self._server = server if self.orm_spawner: if self.orm_spawner.server is not None: # delete the old value db = inspect(self.orm_spawner.server).session db.delete(self.orm_spawner.server) if server is None: self.orm_spawner.server = None else: self.orm_spawner.server = server.orm_server @property def name(self): if self.orm_spawner: return self.orm_spawner.name return '' internal_ssl = Bool(False) internal_trust_bundles = Dict() internal_certs_location = Unicode('') cert_paths = Dict() admin_access = Bool(False) api_token = Unicode() oauth_client_id = Unicode() handler = Any() oauth_roles = Union( [Callable(), List()], help="""Allowed roles for oauth tokens. This sets the maximum and default roles assigned to oauth tokens issued by a single-user server's oauth client (i.e. tokens stored in browsers after authenticating with the server), defining what actions the server 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(config=True) will_resume = Bool( False, help="""Whether the Spawner will resume on next start Default is False where each launch of the Spawner will be a new instance. If True, an existing Spawner will resume instead of starting anew (e.g. resuming a Docker container), and API tokens in use when the Spawner stops will not be deleted. """, ) ip = Unicode( '', help=""" The IP address (or hostname) the single-user server should listen on. The JupyterHub proxy implementation should be able to send packets to this interface. """, ).tag(config=True) port = Integer( 0, help=""" The port for single-user servers to listen on. Defaults to `0`, which uses a randomly allocated port number each time. If set to a non-zero value, all Spawners will use the same port, which only makes sense if each server is on a different address, e.g. in containers. New in version 0.7. """, ).tag(config=True) consecutive_failure_limit = Integer( 0, help=""" Maximum number of consecutive failures to allow before shutting down JupyterHub. This helps JupyterHub recover from a certain class of problem preventing launch in contexts where the Hub is automatically restarted (e.g. systemd, docker, kubernetes). A limit of 0 means no limit and consecutive failures will not be tracked. """, ).tag(config=True) start_timeout = Integer( 60, help=""" Timeout (in seconds) before giving up on starting of single-user server. This is the timeout for start to return, not the timeout for the server to respond. Callers of spawner.start will assume that startup has failed if it takes longer than this. start should return when the server process is started and its location is known. """, ).tag(config=True) http_timeout = Integer( 30, help=""" Timeout (in seconds) before giving up on a spawned HTTP server Once a server has successfully been spawned, this is the amount of time we wait before assuming that the server is unable to accept connections. """, ).tag(config=True) poll_interval = Integer( 30, help=""" Interval (in seconds) on which to poll the spawner for single-user server's status. At every poll interval, each spawner's `.poll` method is called, which checks if the single-user server is still running. If it isn't running, then JupyterHub modifies its own state accordingly and removes appropriate routes from the configurable proxy. """, ).tag(config=True) _callbacks = List() _poll_callback = Any() debug = Bool(False, help="Enable debug-logging of the single-user server").tag( config=True ) options_form = Union( [Unicode(), Callable()], help=""" An HTML form for options a user can specify on launching their server. The surrounding `