support services subdomain

- all services are on the 'services' domain, share the same cookie
This commit is contained in:
Min RK
2016-09-02 13:21:46 +02:00
parent 9a002c2445
commit c3111b04bb
5 changed files with 78 additions and 53 deletions

View File

@@ -292,6 +292,15 @@ class JupyterHub(Application):
# if not specified, assume https: You have to be really explicit about HTTP! # if not specified, assume https: You have to be really explicit about HTTP!
self.subdomain_host = 'https://' + new self.subdomain_host = 'https://' + new
domain = Unicode(
help="domain name without proto,port"
)
@default('domain')
def _domain_default(self):
if not self.subdomain_host:
return ''
return urlparse(self.subdomain_host).hostname
port = Integer(8000, port = Integer(8000,
help="The public facing port of the proxy" help="The public facing port of the proxy"
).tag(config=True) ).tag(config=True)
@@ -960,6 +969,12 @@ class JupyterHub(Application):
def init_services(self): def init_services(self):
self._service_map = {} self._service_map = {}
if self.domain:
domain = 'services.' + self.domain
parsed = urlparse(self.subdomain_host)
host = '%s://services.%s' % (parsed.scheme, parsed.netloc)
else:
domain = host = ''
for spec in self.services: for spec in self.services:
if 'name' not in spec: if 'name' not in spec:
raise ValueError('service spec must have a name: %r' % spec) raise ValueError('service spec must have a name: %r' % spec)
@@ -972,16 +987,38 @@ class JupyterHub(Application):
self.db.add(orm_service) self.db.add(orm_service)
orm_service.admin = spec.get('admin', False) orm_service.admin = spec.get('admin', False)
self.db.commit() self.db.commit()
service = Service( service = Service(parent=self,
proxy=self.proxy, hub=self.hub, base_url=self.base_url, base_url=self.base_url,
db=self.db, orm=orm_service, db=self.db, orm=orm_service,
parent=self, domain=domain, host=host,
hub_api_url=self.hub.api_url) hub_api_url=self.hub.api_url,
)
traits = service.traits(input=True) traits = service.traits(input=True)
for key, value in spec.items(): for key, value in spec.items():
if key not in traits: if key not in traits:
raise AttributeError("No such service field: %s" % key) raise AttributeError("No such service field: %s" % key)
setattr(service, key, value) setattr(service, key, value)
if service.url:
parsed = urlparse(service.url)
if parsed.port is not None:
port = parsed.port
elif parsed.scheme == 'http':
port = 80
elif parsed.scheme == 'https':
port = 443
server = service.orm.server = orm.Server(
proto=parsed.scheme,
ip=parsed.hostname,
port=port,
cookie_name='jupyterhub-services',
base_url=service.prefix,
)
self.db.add(server)
else:
service.orm.server = None
self._service_map[name] = service self._service_map[name] = service
if service.managed: if service.managed:
if not service.api_token: if not service.api_token:
@@ -1193,8 +1230,6 @@ class JupyterHub(Application):
else: else:
version_hash=datetime.now().strftime("%Y%m%d%H%M%S"), version_hash=datetime.now().strftime("%Y%m%d%H%M%S"),
subdomain_host = self.subdomain_host
domain = urlparse(subdomain_host).hostname
settings = dict( settings = dict(
log_function=log_request, log_function=log_request,
config=self.config, config=self.config,
@@ -1217,8 +1252,8 @@ class JupyterHub(Application):
template_path=self.template_paths, template_path=self.template_paths,
jinja2_env=jinja_env, jinja2_env=jinja_env,
version_hash=version_hash, version_hash=version_hash,
subdomain_host=subdomain_host, subdomain_host=self.subdomain_host,
domain=domain, domain=self.domain,
statsd=self.statsd, statsd=self.statsd,
) )
# allow configured settings to have priority # allow configured settings to have priority

View File

@@ -148,29 +148,6 @@ class Service(LoggingConfigurable):
If managed, will be passed as JUPYTERHUB_SERVICE_URL env. If managed, will be passed as JUPYTERHUB_SERVICE_URL env.
""" """
).tag(input=True) ).tag(input=True)
@observe('url')
def _url_changed(self, change):
url = change['new']
if not url:
self.orm.server = None
else:
parsed = urlparse(url)
if parsed.port is not None:
port = parsed.port
elif parsed.scheme == 'http':
port = 80
elif parsed.scheme == 'https':
port = 443
server = self.orm.server = orm.Server(
proto=parsed.scheme,
ip=parsed.hostname,
port=port,
cookie_name='jupyterhub-services',
base_url=self.proxy_path,
)
self.db.add(server)
self.db.commit()
api_token = Unicode( api_token = Unicode(
help="""The API token to use for the service. help="""The API token to use for the service.
@@ -202,24 +179,33 @@ class Service(LoggingConfigurable):
""" """
).tag(input=True) ).tag(input=True)
domain = Unicode()
host = Unicode()
# handles on globals: # handles on globals:
proxy = Any() proxy = Any()
hub = Any()
base_url = Unicode() base_url = Unicode()
db = Any() db = Any()
orm = Any() orm = Any()
@default('orm')
def _orm_default(self):
return self.db.query(orm.Service).filter(orm.Service.name==self.name).first()
@property @property
def server(self): def server(self):
return self.orm.server return self.orm.server
@property @property
def proxy_path(self): def prefix(self):
return url_path_join(self.base_url, 'services', self.name) return url_path_join(self.base_url, 'services', self.name)
@property
def proxy_path(self):
if not self.server:
return ''
if self.domain:
return url_path_join('/' + self.domain, self.server.base_url)
else:
return self.server.base_url
def __repr__(self): def __repr__(self):
return "<{cls}(name={name}{managed})>".format( return "<{cls}(name={name}{managed})>".format(
cls=self.__class__.__name__, cls=self.__class__.__name__,
@@ -239,7 +225,7 @@ class Service(LoggingConfigurable):
env['JUPYTERHUB_API_TOKEN'] = self.api_token env['JUPYTERHUB_API_TOKEN'] = self.api_token
env['JUPYTERHUB_API_URL'] = self.hub_api_url env['JUPYTERHUB_API_URL'] = self.hub_api_url
env['JUPYTERHUB_BASE_URL'] = self.base_url env['JUPYTERHUB_BASE_URL'] = self.base_url
env['JUPYTERHUB_SERVICE_PATH'] = self.proxy_path env['JUPYTERHUB_SERVICE_PATH'] = self.server.base_url
env['JUPYTERHUB_SERVICE_URL'] = self.url env['JUPYTERHUB_SERVICE_URL'] = self.url
self.spawner = _ServiceSpawner( self.spawner = _ServiceSpawner(

View File

@@ -209,8 +209,15 @@ def public_host(app):
return app.proxy.public_server.host return app.proxy.public_server.host
def public_url(app): def public_url(app, user_or_service=None):
"""Return the full, public base URL (including prefix) of the given JupyterHub instance.""" """Return the full, public base URL (including prefix) of the given JupyterHub instance."""
if user_or_service:
if app.subdomain_host:
host = user_or_service.host
else:
host = public_host(app)
return host + user_or_service.server.base_url
else:
return public_host(app) + app.proxy.public_server.base_url return public_host(app) + app.proxy.public_server.base_url
@@ -223,11 +230,7 @@ def user_url(user, app):
Returns: Returns:
url (str): The public URL for user. url (str): The public URL for user.
""" """
if app.subdomain_host: return public_url(app, user)
host = user.host
else:
host = public_host(app)
return host + user.server.base_url
# single-user-server mocking: # single-user-server mocking:

View File

@@ -14,11 +14,13 @@ except ImportError:
from urllib.parse import unquote from urllib.parse import unquote
import pytest import pytest
import requests
from tornado import gen from tornado import gen
from tornado.ioloop import IOLoop from tornado.ioloop import IOLoop
import jupyterhub.services.service import jupyterhub.services.service
from .mocking import public_url
from .test_pages import get_page from .test_pages import get_page
from ..utils import url_path_join, wait_for_http_server from ..utils import url_path_join, wait_for_http_server
@@ -97,16 +99,14 @@ def test_managed_service(app, mockservice):
def test_proxy_service(app, mockservice, io_loop): def test_proxy_service(app, mockservice, io_loop):
name = mockservice.name name = mockservice.name
routes = io_loop.run_sync(app.proxy.get_routes) routes = io_loop.run_sync(app.proxy.get_routes)
assert unquote(mockservice.proxy_path) in routes url = public_url(app, mockservice) + '/foo'
io_loop.run_sync(mockservice.server.wait_up) r = requests.get(url, allow_redirects=False)
path = '/services/{}/foo'.format(name) path = '/services/{}/foo'.format(name)
r = get_page(path, app, hub=False, allow_redirects=False)
r.raise_for_status() r.raise_for_status()
assert r.status_code == 200 assert r.status_code == 200
assert r.text.endswith(path) assert r.text.endswith(path)
@pytest.mark.now
def test_external_service(app, io_loop): def test_external_service(app, io_loop):
name = 'external' name = 'external'
with external_service(app, name=name) as env: with external_service(app, name=name) as env:
@@ -125,9 +125,10 @@ def test_external_service(app, io_loop):
evt.set() evt.set()
app.io_loop.add_callback(add_services) app.io_loop.add_callback(add_services)
assert evt.wait(10) assert evt.wait(10)
service = app._service_map[name]
url = public_url(app, service) + '/api/users'
path = '/services/{}/api/users'.format(name) path = '/services/{}/api/users'.format(name)
r = get_page(path, app, hub=False, allow_redirects=False) r = requests.get(url, allow_redirects=False)
print(r.headers, r.status_code)
r.raise_for_status() r.raise_for_status()
assert r.status_code == 200 assert r.status_code == 200
resp = r.json() resp = r.json()

View File

@@ -173,7 +173,7 @@ class User(HasTraits):
@property @property
def host(self): def host(self):
"""Get the *host* for my server (domain[:port])""" """Get the *host* for my server (proto://domain[:port])"""
# FIXME: escaped_name probably isn't escaped enough in general for a domain fragment # FIXME: escaped_name probably isn't escaped enough in general for a domain fragment
parsed = urlparse(self.settings['subdomain_host']) parsed = urlparse(self.settings['subdomain_host'])
h = '%s://%s.%s' % (parsed.scheme, self.escaped_name, parsed.netloc) h = '%s://%s.%s' % (parsed.scheme, self.escaped_name, parsed.netloc)