diff --git a/docs/source/changelog.md b/docs/source/changelog.md index da9825f1..7cfc9e3b 100644 --- a/docs/source/changelog.md +++ b/docs/source/changelog.md @@ -899,7 +899,7 @@ Bugfixes on 0.6: ### [0.6.0] - 2016-04-25 -- JupyterHub has moved to a new `jupyterhub` namespace on GitHub and Docker. What was `juptyer/jupyterhub` is now `jupyterhub/jupyterhub`, etc. +- JupyterHub has moved to a new `jupyterhub` namespace on GitHub and Docker. What was `jupyter/jupyterhub` is now `jupyterhub/jupyterhub`, etc. - `jupyterhub/jupyterhub` image on DockerHub no longer loads the jupyterhub_config.py in an ONBUILD step. A new `jupyterhub/jupyterhub-onbuild` image does this - Add statsd support, via `c.JupyterHub.statsd_{host,port,prefix}` - Update to traitlets 4.1 `@default`, `@observe` APIs for traits diff --git a/jupyterhub/singleuser/mixins.py b/jupyterhub/singleuser/mixins.py index d3eb2cf1..0248b622 100755 --- a/jupyterhub/singleuser/mixins.py +++ b/jupyterhub/singleuser/mixins.py @@ -10,6 +10,7 @@ with JupyterHub authentication mixins enabled. # Distributed under the terms of the Modified BSD License. import asyncio import json +import logging import os import random import warnings @@ -48,6 +49,17 @@ from ..utils import make_ssl_context from ..utils import url_path_join +def _bool_env(key): + """Cast an environment variable to bool + + 0, empty, or unset is False; All other values are True. + """ + if os.environ.get(key, "") in {"", "0"}: + return False + else: + return True + + # Authenticate requests with the Hub @@ -268,6 +280,10 @@ class SingleUserNotebookAppMixin(Configurable): def _user_changed(self, change): self.log.name = change.new + @default("default_url") + def _default_url(self): + return os.environ.get("JUPYTERHUB_DEFAULT_URL", "/tree/") + hub_host = Unicode().tag(config=True) hub_prefix = Unicode('/hub/').tag(config=True) @@ -350,7 +366,26 @@ class SingleUserNotebookAppMixin(Configurable): """, ).tag(config=True) - @validate('notebook_dir') + @default("disable_user_config") + def _default_disable_user_config(self): + return _bool_env("JUPYTERHUB_DISABLE_USER_CONFIG") + + @default("root_dir") + def _default_root_dir(self): + if os.environ.get("JUPYTERHUB_ROOT_DIR"): + proposal = {"value": os.environ["JUPYTERHUB_ROOT_DIR"]} + # explicitly call validator, not called on default values + return self._notebook_dir_validate(proposal) + else: + return os.getcwd() + + # notebook_dir is used by the classic notebook server + # root_dir is the future in jupyter server + @default("notebook_dir") + def _default_notebook_dir(self): + return self._default_root_dir() + + @validate("notebook_dir", "root_dir") def _notebook_dir_validate(self, proposal): value = os.path.expanduser(proposal['value']) # Strip any trailing slashes @@ -366,6 +401,13 @@ class SingleUserNotebookAppMixin(Configurable): raise TraitError("No such notebook dir: %r" % value) return value + @default('log_level') + def _log_level_defaul(self): + if _bool_env("JUPYTERHUB_DEBUG"): + return logging.DEBUG + else: + return logging.INFO + @default('log_datefmt') def _log_datefmt_default(self): """Exclude date from default date format""" diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index cd9bcced..00c6d1cf 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -797,8 +797,27 @@ class Spawner(LoggingConfigurable): 'activity', ) env['JUPYTERHUB_BASE_URL'] = self.hub.base_url[:-4] + if self.server: + base_url = self.server.base_url + if self.ip or self.port: + self.server.ip = self.ip + self.server.port = self.port env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url + else: + # this should only occur in mock/testing schenarios + base_url = '/' + + if self.ip or self.port: + # specify JUPYTERHUB_SERVICE_URL *if* ip or port is specified + # TODO: set this always? + # Prior to 2.0, leaving 'ip' or 'port' unset meant letting the subprocess default be used + # setting via a URL means we cannot specify explicit ip or port without specifying *both* + # this results in a changed default behavior of specifying only report + # specifying ip='' *explicitly*, which is the same as all interfaces, instead of localhost + s = 's' if self.internal_ssl else '' + bind_url = f"http{s}://{self.ip}:{self.port}{base_url}" + env["JUPYTERHUB_SERVICE_URL"] = bind_url # Put in limit and guarantee info if they exist. # Note that this is for use by the humans / notebook extensions in the @@ -818,6 +837,20 @@ class Spawner(LoggingConfigurable): env['JUPYTERHUB_SSL_CERTFILE'] = self.cert_paths['certfile'] env['JUPYTERHUB_SSL_CLIENT_CA'] = self.cert_paths['cafile'] + if self.notebook_dir: + notebook_dir = self.format_string(self.notebook_dir) + env["JUPYTERHUB_ROOT_DIR"] = notebook_dir + + if self.default_url: + default_url = self.format_string(self.default_url) + env["JUPYTERHUB_DEFAULT_URL"] = default_url + + if self.debug: + env["JUPYTERHUB_DEBUG"] = "1" + + if self.disable_user_config: + env["JUPYTERHUB_DISABLE_USER_CONFIG"] = "1" + # env overrides from config. If the value is a callable, it will be called with # one parameter - the current spawner instance - and the return value # will be assigned to the environment variable. This will be called at @@ -829,7 +862,6 @@ class Spawner(LoggingConfigurable): env[key] = value(self) else: env[key] = value - return env async def get_url(self): @@ -996,24 +1028,16 @@ class Spawner(LoggingConfigurable): """Return the arguments to be passed after self.cmd Doesn't expect shell expansion to happen. + + .. versionchanged:: 2.0 + Prior to 2.0, JupyterHub passed some options such as + ip, port, and default_url to the command-line. + JupyterHub 2.0 no longer builds any CLI args + other than `Spawner.cmd` and `Spawner.args`. + All values that come from jupyterhub itself + will be passed via environment variables. """ - args = [] - - if self.notebook_dir: - notebook_dir = self.format_string(self.notebook_dir) - args.append('--notebook-dir=%s' % _quote_safe(notebook_dir)) - if self.default_url: - default_url = self.format_string(self.default_url) - args.append( - '--SingleUserNotebookApp.default_url=%s' % _quote_safe(default_url) - ) - - if self.debug: - args.append('--debug') - if self.disable_user_config: - args.append('--disable-user-config') - args.extend(self.args) - return args + return self.args def run_pre_spawn_hook(self): """Run the pre_spawn_hook if defined""" @@ -1269,6 +1293,11 @@ class LocalProcessSpawner(Spawner): Note: This spawner does not implement CPU / memory guarantees and limits. """ + @default('ip') + def _default_ip(self): + """Listen on localhost by default for local processes""" + return '127.0.0.1' + interrupt_timeout = Integer( 10, help=""" diff --git a/jupyterhub/tests/mocksu.py b/jupyterhub/tests/mocksu.py index c5714cff..c9f4b85e 100644 --- a/jupyterhub/tests/mocksu.py +++ b/jupyterhub/tests/mocksu.py @@ -11,10 +11,10 @@ Handlers and their purpose include: - ArgsHandler: allowing retrieval of `sys.argv`. """ -import argparse import json import os import sys +from urllib.parse import urlparse from tornado import httpserver from tornado import ioloop @@ -36,7 +36,8 @@ class ArgsHandler(web.RequestHandler): self.write(json.dumps(sys.argv)) -def main(args): +def main(): + url = urlparse(os.environ["JUPYTERHUB_SERVICE_URL"]) options.logging = 'debug' log.enable_pretty_logging() app = web.Application( @@ -50,10 +51,11 @@ def main(args): if key and cert and ca: ssl_context = make_ssl_context(key, cert, cafile=ca, check_hostname=False) + assert url.scheme == "https" server = httpserver.HTTPServer(app, ssl_options=ssl_context) - log.app_log.info("Starting mock singleuser server at 127.0.0.1:%s", args.port) - server.listen(args.port, '127.0.0.1') + log.app_log.info(f"Starting mock singleuser server at {url.hostname}:{url.port}") + server.listen(url.port, url.hostname) try: ioloop.IOLoop.instance().start() except KeyboardInterrupt: @@ -61,7 +63,4 @@ def main(args): if __name__ == '__main__': - parser = argparse.ArgumentParser() - parser.add_argument('--port', type=int) - args, extra = parser.parse_known_args() - main(args) + main() diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index 25b03566..2e86f41c 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -565,10 +565,17 @@ async def test_spawn(app): r = await async_requests.get(ujoin(url, 'args'), **kwargs) assert r.status_code == 200 argv = r.json() - assert '--port' in ' '.join(argv) + assert '--port' not in ' '.join(argv) + # we pass no CLI args anymore: + assert len(argv) == 1 r = await async_requests.get(ujoin(url, 'env'), **kwargs) env = r.json() - for expected in ['JUPYTERHUB_USER', 'JUPYTERHUB_BASE_URL', 'JUPYTERHUB_API_TOKEN']: + for expected in [ + 'JUPYTERHUB_USER', + 'JUPYTERHUB_BASE_URL', + 'JUPYTERHUB_API_TOKEN', + 'JUPYTERHUB_SERVICE_URL', + ]: assert expected in env if app.subdomain_host: assert env['JUPYTERHUB_HOST'] == app.subdomain_host