""" 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 shlex import shutil import signal import sys import warnings from inspect import signature from subprocess import Popen from tempfile import mkdtemp from textwrap import dedent from urllib.parse import urlparse if sys.version_info >= (3, 10): from contextlib import aclosing else: from async_generator import aclosing from sqlalchemy import inspect from tornado.ioloop import PeriodicCallback from traitlets import ( Any, Bool, Dict, Float, Instance, Integer, List, Unicode, Union, default, observe, validate, ) from traitlets.config import LoggingConfigurable from . import orm from .objects import Server from .roles import roles_to_scopes from .traitlets import ByteSpecification, Callable, Command from .utils import ( AnyTimeoutError, exponential_backoff, maybe_future, random_port, url_escape_path, url_path_join, ) if os.name == 'nt': import psutil 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.user: user_name = self.user.name else: # no user, only happens in mock tests user_name = "(no user)" if self.name: return f"{user_name}:{self.name}" else: return 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() cookie_options = Dict() cookie_host_prefix_enabled = Bool() public_url = Unicode(help="Public URL of this spawner's server") public_hub_url = Unicode(help="Public URL of the Hub itself") db = Any() @default("db") def _deprecated_db(self): self.log.warning( dedent( """ The shared database session at Spawner.db is deprecated, and will be removed. Please manage your own database and connections. Contact JupyterHub at https://github.com/jupyterhub/jupyterhub/issues/3700 if you have questions or ideas about direct database needs for your Spawner. """ ), ) return self._deprecated_db_session _deprecated_db_session = Any() @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 # Spawner.server is a wrapper of the ORM orm_spawner.server # make sure it's always in sync with the underlying state # this is harder to do with traitlets, # which do not run on every access, only on set and first-get _server = None @property def server(self): # always check that we're in sync with orm_spawner if not self.orm_spawner: # no ORM spawner, nothing to check return self._server orm_server = self.orm_spawner.server if orm_server is not None and ( self._server is None or orm_server is not self._server.orm_server ): # self._server is not connected to orm_spawner self._server = Server(orm_server=self.orm_spawner.server) elif orm_server is None: # no ORM server, clear it self._server = None return self._server @server.setter def server(self, server): self._server = server if self.orm_spawner is not None: if server is not None and server.orm_server == self.orm_spawner.server: # no change return 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: if server.orm_server is None: self.log.warning(f"No ORM server for {self._log_name}") self.orm_spawner.server = server.orm_server elif server is not None: self.log.warning( f"Setting Spawner.server for {self._log_name} with no underlying orm_spawner" ) @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() @property def oauth_scopes(self): warnings.warn( """Spawner.oauth_scopes is deprecated in JupyterHub 2.3. Use Spawner.oauth_access_scopes """, DeprecationWarning, stacklevel=2, ) return self.oauth_access_scopes oauth_access_scopes = List( Unicode(), help="""The scope(s) needed to access this server""", ) @default("oauth_access_scopes") def _default_access_scopes(self): return [ f"access:servers!server={self.user.name}/{self.name}", f"access:servers!user={self.user.name}", ] handler = Any() oauth_roles = Union( [Callable(), List()], help="""Allowed roles for oauth tokens. Deprecated in 3.0: use oauth_client_allowed_scopes 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) oauth_client_allowed_scopes = Union( [Callable(), List()], help="""Allowed scopes for oauth tokens issued by this server's oauth client. This sets the maximum and default scopes 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. If callable, will be called with the Spawner as a single argument. Callables may be async. """, ).tag(config=True) async def _get_oauth_client_allowed_scopes(self): """Private method: get oauth allowed scopes Handle: - oauth_client_allowed_scopes - callable config - deprecated oauth_roles config - access_scopes """ # cases: # 1. only scopes # 2. only roles # 3. both! (conflict, favor scopes) scopes = [] if self.oauth_client_allowed_scopes: allowed_scopes = self.oauth_client_allowed_scopes if callable(allowed_scopes): allowed_scopes = allowed_scopes(self) if inspect.isawaitable(allowed_scopes): allowed_scopes = await allowed_scopes scopes.extend(allowed_scopes) if self.oauth_roles: if scopes: # both defined! Warn warnings.warn( f"Ignoring deprecated Spawner.oauth_roles={self.oauth_roles} in favor of Spawner.oauth_client_allowed_scopes.", ) else: role_names = self.oauth_roles if callable(role_names): role_names = role_names(self) roles = list( self.db.query(orm.Role).filter(orm.Role.name.in_(role_names)) ) if len(roles) != len(role_names): missing_roles = set(role_names).difference( {role.name for role in roles} ) raise ValueError(f"No such role(s): {', '.join(missing_roles)}") scopes.extend(roles_to_scopes(roles)) # always add access scope scopes.append(f"access:servers!server={self.user.name}/{self.name}") return sorted(set(scopes)) server_token_scopes = Union( [List(Unicode()), Callable()], help="""The list of scopes to request for $JUPYTERHUB_API_TOKEN If not specified, the scopes in the `server` role will be used (unchanged from pre-4.0). If callable, will be called with the Spawner instance as its sole argument (JupyterHub user available as spawner.user). JUPYTERHUB_API_TOKEN will be assigned the _subset_ of these scopes that are held by the user (as in oauth_client_allowed_scopes). .. versionadded:: 4.0 """, ).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( '127.0.0.1', help=""" The IP address (or hostname) the single-user server should listen on. Usually either '127.0.0.1' (default) or '0.0.0.0'. The JupyterHub proxy implementation should be able to send packets to this interface. Subclasses which launch remotely or in containers should override the default to '0.0.0.0'. .. versionchanged:: 2.0 Default changed to '127.0.0.1', from ''. In most cases, this does not result in a change in behavior, as '' was interpreted as 'unspecified', which used the subprocesses' own default, itself usually '127.0.0.1'. """, ).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) poll_jitter = Float( 0.1, min=0, max=1, help=""" Jitter fraction for poll_interval. Avoids alignment of poll calls for many Spawners, e.g. when restarting JupyterHub, which restarts all polls for running Spawners. `poll_jitter=0` means no jitter, 0.1 means 10%, etc. """, ).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 `