|
@@ -1,533 +1,18 @@
|
|
|
-#!/usr/bin/env python
|
|
|
-import asyncio
|
|
|
-import os
|
|
|
-from urllib.parse import urlparse
|
|
|
+# coding: utf-8
|
|
|
+"""A JupyterHub EntryPoint that defaults to use JupyterLab"""
|
|
|
|
|
|
-from traitlets import (
|
|
|
- Any,
|
|
|
- Bool,
|
|
|
- Bytes,
|
|
|
- Integer,
|
|
|
- Unicode,
|
|
|
- CUnicode,
|
|
|
- default,
|
|
|
- observe,
|
|
|
- validate,
|
|
|
- TraitError,
|
|
|
- Type
|
|
|
-)
|
|
|
+# Copyright (c) Jupyter Development Team.
|
|
|
+# Distributed under the terms of the Modified BSD License.
|
|
|
|
|
|
-try:
|
|
|
- # Available in JupyterHub 1.2+
|
|
|
- from jupyterhub.singleuser.mixins import make_singleuser_app
|
|
|
+from jupyterhub.singleuser import SingleUserNotebookApp
|
|
|
|
|
|
-except ImportError:
|
|
|
- make_singleuser_app = None
|
|
|
- from jupyterhub.singleuser import (
|
|
|
- dedent, exponential_backoff, make_ssl_context, url_path_join, _check_version, _exclude_home,
|
|
|
- ChoiceLoader, HubOAuth, JupyterHubLoginHandler, JupyterHubLogoutHandler
|
|
|
- )
|
|
|
|
|
|
-from tornado.httpclient import AsyncHTTPClient
|
|
|
-from tornado.httpclient import HTTPRequest
|
|
|
-from tornado import web
|
|
|
-
|
|
|
-from .labapp import LabApp, aliases as lab_aliases, flags as lab_flags
|
|
|
-from ._version import __version__
|
|
|
-
|
|
|
-
|
|
|
-# register new hub related command-line aliases
|
|
|
-aliases = dict(lab_flags)
|
|
|
-aliases.update(
|
|
|
- {
|
|
|
- 'user': 'SingleUserNotebookApp.user',
|
|
|
- 'group': 'SingleUserNotebookApp.group',
|
|
|
- 'cookie-name': 'HubAuth.cookie_name',
|
|
|
- 'hub-prefix': 'SingleUserNotebookApp.hub_prefix',
|
|
|
- 'hub-host': 'SingleUserNotebookApp.hub_host',
|
|
|
- 'hub-api-url': 'SingleUserNotebookApp.hub_api_url',
|
|
|
- 'base-url': 'SingleUserNotebookApp.base_url',
|
|
|
- }
|
|
|
-)
|
|
|
-flags = dict(lab_flags)
|
|
|
-flags.update(
|
|
|
- {
|
|
|
- 'disable-user-config': (
|
|
|
- {'SingleUserNotebookApp': {'disable_user_config': True}},
|
|
|
- "Disable user-controlled configuration of the notebook server.",
|
|
|
- )
|
|
|
- }
|
|
|
-)
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
-if make_singleuser_app:
|
|
|
-
|
|
|
- class SingleUserNotebookMixin(LabApp):
|
|
|
-
|
|
|
- login_handler_class = Type(klass=web.RequestHandler).tag(config=True)
|
|
|
- logout_handler_class = Type(klass=web.RequestHandler).tag(config=True)
|
|
|
-
|
|
|
- SingleUserNotebookApp = make_singleuser_app(SingleUserNotebookMixin)
|
|
|
-
|
|
|
-else:
|
|
|
-
|
|
|
- class SingleUserNotebookApp(LabApp):
|
|
|
- """A Subclass of the regular LabApp that is aware of the parent multiuser context."""
|
|
|
-
|
|
|
- description = dedent(
|
|
|
- """
|
|
|
- Single-user server for JupyterHub. Extends the Jupyter Notebook server.
|
|
|
-
|
|
|
- Meant to be invoked by JupyterHub Spawners, and not directly.
|
|
|
- """
|
|
|
- )
|
|
|
-
|
|
|
- examples = ""
|
|
|
- subcommands = {}
|
|
|
- version = __version__
|
|
|
- classes = LabApp.classes + [HubOAuth]
|
|
|
-
|
|
|
- # disable single-user app's localhost checking
|
|
|
- allow_remote_access = True
|
|
|
-
|
|
|
- # don't store cookie secrets
|
|
|
- cookie_secret_file = ''
|
|
|
- # always generate a new cookie secret on launch
|
|
|
- # ensures that each spawn clears any cookies from previous session,
|
|
|
- # triggering OAuth again
|
|
|
- cookie_secret = Bytes()
|
|
|
-
|
|
|
- def _cookie_secret_default(self):
|
|
|
- return os.urandom(32)
|
|
|
-
|
|
|
- user = CUnicode().tag(config=True)
|
|
|
- group = CUnicode().tag(config=True)
|
|
|
-
|
|
|
- @default('user')
|
|
|
- def _default_user(self):
|
|
|
- return os.environ.get('JUPYTERHUB_USER') or ''
|
|
|
-
|
|
|
- @default('group')
|
|
|
- def _default_group(self):
|
|
|
- return os.environ.get('JUPYTERHUB_GROUP') or ''
|
|
|
-
|
|
|
- @observe('user')
|
|
|
- def _user_changed(self, change):
|
|
|
- self.log.name = change.new
|
|
|
-
|
|
|
- hub_host = Unicode().tag(config=True)
|
|
|
-
|
|
|
- hub_prefix = Unicode('/hub/').tag(config=True)
|
|
|
-
|
|
|
- @default('keyfile')
|
|
|
- def _keyfile_default(self):
|
|
|
- return os.environ.get('JUPYTERHUB_SSL_KEYFILE') or ''
|
|
|
-
|
|
|
- @default('certfile')
|
|
|
- def _certfile_default(self):
|
|
|
- return os.environ.get('JUPYTERHUB_SSL_CERTFILE') or ''
|
|
|
-
|
|
|
- @default('client_ca')
|
|
|
- def _client_ca_default(self):
|
|
|
- return os.environ.get('JUPYTERHUB_SSL_CLIENT_CA') or ''
|
|
|
-
|
|
|
- @default('hub_prefix')
|
|
|
- def _hub_prefix_default(self):
|
|
|
- base_url = os.environ.get('JUPYTERHUB_BASE_URL') or '/'
|
|
|
- return base_url + 'hub/'
|
|
|
-
|
|
|
- hub_api_url = Unicode().tag(config=True)
|
|
|
-
|
|
|
- @default('hub_api_url')
|
|
|
- def _hub_api_url_default(self):
|
|
|
- return os.environ.get('JUPYTERHUB_API_URL') or 'http://127.0.0.1:8081/hub/api'
|
|
|
-
|
|
|
- # defaults for some configurables that may come from service env variables:
|
|
|
- @default('base_url')
|
|
|
- def _base_url_default(self):
|
|
|
- return os.environ.get('JUPYTERHUB_SERVICE_PREFIX') or '/'
|
|
|
-
|
|
|
- # Note: this may be removed if notebook module is >= 5.0.0b1
|
|
|
- @validate('base_url')
|
|
|
- def _validate_base_url(self, proposal):
|
|
|
- """ensure base_url starts and ends with /"""
|
|
|
- value = proposal.value
|
|
|
- if not value.startswith('/'):
|
|
|
- value = '/' + value
|
|
|
- if not value.endswith('/'):
|
|
|
- value = value + '/'
|
|
|
- return value
|
|
|
-
|
|
|
- @default('port')
|
|
|
- def _port_default(self):
|
|
|
- if os.environ.get('JUPYTERHUB_SERVICE_URL'):
|
|
|
- url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
|
|
|
- if url.port:
|
|
|
- return url.port
|
|
|
- elif url.scheme == 'http':
|
|
|
- return 80
|
|
|
- elif url.scheme == 'https':
|
|
|
- return 443
|
|
|
- return 8888
|
|
|
-
|
|
|
- @default('ip')
|
|
|
- def _ip_default(self):
|
|
|
- if os.environ.get('JUPYTERHUB_SERVICE_URL'):
|
|
|
- url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
|
|
|
- if url.hostname:
|
|
|
- return url.hostname
|
|
|
- return '127.0.0.1'
|
|
|
-
|
|
|
- aliases = aliases
|
|
|
- flags = flags
|
|
|
-
|
|
|
- # disble some single-user configurables
|
|
|
- token = ''
|
|
|
- open_browser = False
|
|
|
- quit_button = False
|
|
|
- trust_xheaders = True
|
|
|
- login_handler_class = JupyterHubLoginHandler
|
|
|
- logout_handler_class = JupyterHubLogoutHandler
|
|
|
- port_retries = (
|
|
|
- 0 # disable port-retries, since the Spawner will tell us what port to use
|
|
|
- )
|
|
|
-
|
|
|
- disable_user_config = Bool(
|
|
|
- False,
|
|
|
- help="""Disable user configuration of single-user server.
|
|
|
-
|
|
|
- Prevents user-writable files that normally configure the single-user server
|
|
|
- from being loaded, ensuring admins have full control of configuration.
|
|
|
- """,
|
|
|
- ).tag(config=True)
|
|
|
-
|
|
|
- @validate('notebook_dir')
|
|
|
- def _notebook_dir_validate(self, proposal):
|
|
|
- value = os.path.expanduser(proposal['value'])
|
|
|
- # Strip any trailing slashes
|
|
|
- # *except* if it's root
|
|
|
- _, path = os.path.splitdrive(value)
|
|
|
- if path == os.sep:
|
|
|
- return value
|
|
|
- value = value.rstrip(os.sep)
|
|
|
- if not os.path.isabs(value):
|
|
|
- # If we receive a non-absolute path, make it absolute.
|
|
|
- value = os.path.abspath(value)
|
|
|
- if not os.path.isdir(value):
|
|
|
- raise TraitError("No such notebook dir: %r" % value)
|
|
|
- return value
|
|
|
-
|
|
|
- @default('log_datefmt')
|
|
|
- def _log_datefmt_default(self):
|
|
|
- """Exclude date from default date format"""
|
|
|
- return "%Y-%m-%d %H:%M:%S"
|
|
|
-
|
|
|
- @default('log_format')
|
|
|
- def _log_format_default(self):
|
|
|
- """override default log format to include time"""
|
|
|
- return "%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s %(module)s:%(lineno)d]%(end_color)s %(message)s"
|
|
|
-
|
|
|
- def _confirm_exit(self):
|
|
|
- # disable the exit confirmation for background notebook processes
|
|
|
- self.io_loop.add_callback_from_signal(self.io_loop.stop)
|
|
|
-
|
|
|
- def migrate_config(self):
|
|
|
- if self.disable_user_config:
|
|
|
- # disable config-migration when user config is disabled
|
|
|
- return
|
|
|
- else:
|
|
|
- super(SingleUserNotebookApp, self).migrate_config()
|
|
|
-
|
|
|
- @property
|
|
|
- def config_file_paths(self):
|
|
|
- path = super(SingleUserNotebookApp, self).config_file_paths
|
|
|
-
|
|
|
- if self.disable_user_config:
|
|
|
- # filter out user-writable config dirs if user config is disabled
|
|
|
- path = list(_exclude_home(path))
|
|
|
- return path
|
|
|
-
|
|
|
- @property
|
|
|
- def nbextensions_path(self):
|
|
|
- path = super(SingleUserNotebookApp, self).nbextensions_path
|
|
|
-
|
|
|
- if self.disable_user_config:
|
|
|
- path = list(_exclude_home(path))
|
|
|
- return path
|
|
|
-
|
|
|
- @validate('static_custom_path')
|
|
|
- def _validate_static_custom_path(self, proposal):
|
|
|
- path = proposal['value']
|
|
|
- if self.disable_user_config:
|
|
|
- path = list(_exclude_home(path))
|
|
|
- return path
|
|
|
-
|
|
|
- # create dynamic default http client,
|
|
|
- # configured with any relevant ssl config
|
|
|
- hub_http_client = Any()
|
|
|
-
|
|
|
- @default('hub_http_client')
|
|
|
- def _default_client(self):
|
|
|
- ssl_context = make_ssl_context(
|
|
|
- self.keyfile, self.certfile, cafile=self.client_ca
|
|
|
- )
|
|
|
- AsyncHTTPClient.configure(None, defaults={"ssl_options": ssl_context})
|
|
|
- return AsyncHTTPClient()
|
|
|
-
|
|
|
- async def check_hub_version(self):
|
|
|
- """Test a connection to my Hub
|
|
|
-
|
|
|
- - exit if I can't connect at all
|
|
|
- - check version and warn on sufficient mismatch
|
|
|
- """
|
|
|
- client = self.hub_http_client
|
|
|
- RETRIES = 5
|
|
|
- for i in range(1, RETRIES + 1):
|
|
|
- try:
|
|
|
- resp = await client.fetch(self.hub_api_url)
|
|
|
- except Exception:
|
|
|
- self.log.exception(
|
|
|
- "Failed to connect to my Hub at %s (attempt %i/%i). Is it running?",
|
|
|
- self.hub_api_url,
|
|
|
- i,
|
|
|
- RETRIES,
|
|
|
- )
|
|
|
- await gen.sleep(min(2 ** i, 16))
|
|
|
- else:
|
|
|
- break
|
|
|
- else:
|
|
|
- self.exit(1)
|
|
|
-
|
|
|
- hub_version = resp.headers.get('X-JupyterHub-Version')
|
|
|
- _check_version(hub_version, __version__, self.log)
|
|
|
-
|
|
|
- server_name = Unicode()
|
|
|
-
|
|
|
- @default('server_name')
|
|
|
- def _server_name_default(self):
|
|
|
- return os.environ.get('JUPYTERHUB_SERVER_NAME', '')
|
|
|
-
|
|
|
- hub_activity_url = Unicode(
|
|
|
- config=True, help="URL for sending JupyterHub activity updates"
|
|
|
- )
|
|
|
-
|
|
|
- @default('hub_activity_url')
|
|
|
- def _default_activity_url(self):
|
|
|
- return os.environ.get('JUPYTERHUB_ACTIVITY_URL', '')
|
|
|
-
|
|
|
- hub_activity_interval = Integer(
|
|
|
- 300,
|
|
|
- config=True,
|
|
|
- help="""
|
|
|
- Interval (in seconds) on which to update the Hub
|
|
|
- with our latest activity.
|
|
|
- """,
|
|
|
- )
|
|
|
-
|
|
|
- @default('hub_activity_interval')
|
|
|
- def _default_activity_interval(self):
|
|
|
- env_value = os.environ.get('JUPYTERHUB_ACTIVITY_INTERVAL')
|
|
|
- if env_value:
|
|
|
- return int(env_value)
|
|
|
- else:
|
|
|
- return 300
|
|
|
-
|
|
|
- _last_activity_sent = Any(allow_none=True)
|
|
|
-
|
|
|
- async def notify_activity(self):
|
|
|
- """Notify jupyterhub of activity"""
|
|
|
- client = self.hub_http_client
|
|
|
- last_activity = self.web_app.last_activity()
|
|
|
- if not last_activity:
|
|
|
- self.log.debug("No activity to send to the Hub")
|
|
|
- return
|
|
|
- if last_activity:
|
|
|
- # protect against mixed timezone comparisons
|
|
|
- if not last_activity.tzinfo:
|
|
|
- # assume naive timestamps are utc
|
|
|
- self.log.warning("last activity is using naive timestamps")
|
|
|
- last_activity = last_activity.replace(tzinfo=timezone.utc)
|
|
|
-
|
|
|
- if self._last_activity_sent and last_activity < self._last_activity_sent:
|
|
|
- self.log.debug("No activity since %s", self._last_activity_sent)
|
|
|
- return
|
|
|
-
|
|
|
- last_activity_timestamp = isoformat(last_activity)
|
|
|
-
|
|
|
- async def notify():
|
|
|
- self.log.debug("Notifying Hub of activity %s", last_activity_timestamp)
|
|
|
- req = HTTPRequest(
|
|
|
- url=self.hub_activity_url,
|
|
|
- method='POST',
|
|
|
- headers={
|
|
|
- "Authorization": "token {}".format(self.hub_auth.api_token),
|
|
|
- "Content-Type": "application/json",
|
|
|
- },
|
|
|
- body=json.dumps(
|
|
|
- {
|
|
|
- 'servers': {
|
|
|
- self.server_name: {'last_activity': last_activity_timestamp}
|
|
|
- },
|
|
|
- 'last_activity': last_activity_timestamp,
|
|
|
- }
|
|
|
- ),
|
|
|
- )
|
|
|
- try:
|
|
|
- await client.fetch(req)
|
|
|
- except Exception:
|
|
|
- self.log.exception("Error notifying Hub of activity")
|
|
|
- return False
|
|
|
- else:
|
|
|
- return True
|
|
|
-
|
|
|
- await exponential_backoff(
|
|
|
- notify,
|
|
|
- fail_message="Failed to notify Hub of activity",
|
|
|
- start_wait=1,
|
|
|
- max_wait=15,
|
|
|
- timeout=60,
|
|
|
- )
|
|
|
- self._last_activity_sent = last_activity
|
|
|
-
|
|
|
- async def keep_activity_updated(self):
|
|
|
- if not self.hub_activity_url or not self.hub_activity_interval:
|
|
|
- self.log.warning("Activity events disabled")
|
|
|
- return
|
|
|
- self.log.info(
|
|
|
- "Updating Hub with activity every %s seconds", self.hub_activity_interval
|
|
|
- )
|
|
|
- while True:
|
|
|
- try:
|
|
|
- await self.notify_activity()
|
|
|
- except Exception as e:
|
|
|
- self.log.exception("Error notifying Hub of activity")
|
|
|
- # add 20% jitter to the interval to avoid alignment
|
|
|
- # of lots of requests from user servers
|
|
|
- t = self.hub_activity_interval * (1 + 0.2 * (random.random() - 0.5))
|
|
|
- await asyncio.sleep(t)
|
|
|
-
|
|
|
- def initialize(self, argv=None):
|
|
|
- # disable trash by default
|
|
|
- # this can be re-enabled by config
|
|
|
- self.config.FileContentsManager.delete_to_trash = False
|
|
|
- return super().initialize(argv)
|
|
|
-
|
|
|
- def start(self):
|
|
|
- self.log.info("Starting jupyterhub-singleuser server version %s", __version__)
|
|
|
- # start by hitting Hub to check version
|
|
|
- ioloop.IOLoop.current().run_sync(self.check_hub_version)
|
|
|
- ioloop.IOLoop.current().add_callback(self.keep_activity_updated)
|
|
|
- super(SingleUserNotebookApp, self).start()
|
|
|
-
|
|
|
- def init_hub_auth(self):
|
|
|
- api_token = None
|
|
|
- if os.getenv('JPY_API_TOKEN'):
|
|
|
- # Deprecated env variable (as of 0.7.2)
|
|
|
- api_token = os.environ['JPY_API_TOKEN']
|
|
|
- if os.getenv('JUPYTERHUB_API_TOKEN'):
|
|
|
- api_token = os.environ['JUPYTERHUB_API_TOKEN']
|
|
|
-
|
|
|
- if not api_token:
|
|
|
- self.exit(
|
|
|
- "JUPYTERHUB_API_TOKEN env is required to run jupyterhub-singleuser. Did you launch it manually?"
|
|
|
- )
|
|
|
- self.hub_auth = HubOAuth(
|
|
|
- parent=self,
|
|
|
- api_token=api_token,
|
|
|
- api_url=self.hub_api_url,
|
|
|
- hub_prefix=self.hub_prefix,
|
|
|
- base_url=self.base_url,
|
|
|
- keyfile=self.keyfile,
|
|
|
- certfile=self.certfile,
|
|
|
- client_ca=self.client_ca,
|
|
|
- )
|
|
|
- # smoke check
|
|
|
- if not self.hub_auth.oauth_client_id:
|
|
|
- raise ValueError("Missing OAuth client ID")
|
|
|
-
|
|
|
- def init_webapp(self):
|
|
|
- # load the hub-related settings into the tornado settings dict
|
|
|
- self.init_hub_auth()
|
|
|
- s = self.tornado_settings
|
|
|
- s['log_function'] = log_request
|
|
|
- s['user'] = self.user
|
|
|
- s['group'] = self.group
|
|
|
- s['hub_prefix'] = self.hub_prefix
|
|
|
- s['hub_host'] = self.hub_host
|
|
|
- s['hub_auth'] = self.hub_auth
|
|
|
- csp_report_uri = s['csp_report_uri'] = self.hub_host + url_path_join(
|
|
|
- self.hub_prefix, 'security/csp-report'
|
|
|
- )
|
|
|
- headers = s.setdefault('headers', {})
|
|
|
- headers['X-JupyterHub-Version'] = __version__
|
|
|
- # set CSP header directly to workaround bugs in jupyter/notebook 5.0
|
|
|
- headers.setdefault(
|
|
|
- 'Content-Security-Policy',
|
|
|
- ';'.join(["frame-ancestors 'self'", "report-uri " + csp_report_uri]),
|
|
|
- )
|
|
|
- super(SingleUserNotebookApp, self).init_webapp()
|
|
|
-
|
|
|
- # add OAuth callback
|
|
|
- self.web_app.add_handlers(
|
|
|
- r".*$",
|
|
|
- [(urlparse(self.hub_auth.oauth_redirect_uri).path, OAuthCallbackHandler)],
|
|
|
- )
|
|
|
-
|
|
|
- # apply X-JupyterHub-Version to *all* request handlers (even redirects)
|
|
|
- self.patch_default_headers()
|
|
|
- self.patch_templates()
|
|
|
-
|
|
|
- def patch_default_headers(self):
|
|
|
- if hasattr(RequestHandler, '_orig_set_default_headers'):
|
|
|
- return
|
|
|
- RequestHandler._orig_set_default_headers = RequestHandler.set_default_headers
|
|
|
-
|
|
|
- def set_jupyterhub_header(self):
|
|
|
- self._orig_set_default_headers()
|
|
|
- self.set_header('X-JupyterHub-Version', __version__)
|
|
|
-
|
|
|
- RequestHandler.set_default_headers = set_jupyterhub_header
|
|
|
-
|
|
|
- def patch_templates(self):
|
|
|
- """Patch page templates to add Hub-related buttons"""
|
|
|
-
|
|
|
- self.jinja_template_vars['logo_url'] = self.hub_host + url_path_join(
|
|
|
- self.hub_prefix, 'logo'
|
|
|
- )
|
|
|
- self.jinja_template_vars['hub_host'] = self.hub_host
|
|
|
- self.jinja_template_vars['hub_prefix'] = self.hub_prefix
|
|
|
- env = self.web_app.settings['jinja2_env']
|
|
|
-
|
|
|
- env.globals['hub_control_panel_url'] = self.hub_host + url_path_join(
|
|
|
- self.hub_prefix, 'home'
|
|
|
- )
|
|
|
-
|
|
|
- # patch jinja env loading to modify page template
|
|
|
- def get_page(name):
|
|
|
- if name == 'page.html':
|
|
|
- return page_template
|
|
|
-
|
|
|
- orig_loader = env.loader
|
|
|
- env.loader = ChoiceLoader([FunctionLoader(get_page), orig_loader])
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
-# Overrides for Jupyter Server Extension config
|
|
|
-class OverrideSingleUserNotebookApp(SingleUserNotebookApp):
|
|
|
- name = 'labhub'
|
|
|
-
|
|
|
- # Disable the default jupyterlab extension and enable ourself
|
|
|
- serverapp_config = {
|
|
|
- "jpserver_extensions": { "jupyterlab": False, "jupyterlab.labhubapp": True }
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
-load_jupyter_server_extension = OverrideSingleUserNotebookApp._load_jupyter_server_extension
|
|
|
+class SingleUserLabApp(SingleUserNotebookApp):
|
|
|
+ default_url = '/lab'
|
|
|
|
|
|
|
|
|
def main(argv=None):
|
|
|
- return OverrideSingleUserNotebookApp.launch_instance(argv)
|
|
|
+ return SingleUserLabApp.launch_instance(argv)
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|