mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-15 22:13:00 +00:00
support services subdomain
- all services are on the 'services' domain, share the same cookie
This commit is contained in:
@@ -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
|
||||||
|
@@ -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.
|
||||||
|
|
||||||
@@ -201,25 +178,34 @@ class Service(LoggingConfigurable):
|
|||||||
If unspecified, run the service as the same user as the Hub.
|
If unspecified, run the service as the same user as the Hub.
|
||||||
"""
|
"""
|
||||||
).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(
|
||||||
|
@@ -209,9 +209,16 @@ 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."""
|
||||||
return public_host(app) + app.proxy.public_server.base_url
|
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
|
||||||
|
|
||||||
|
|
||||||
def user_url(user, app):
|
def user_url(user, app):
|
||||||
@@ -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:
|
||||||
|
|
||||||
|
@@ -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()
|
||||||
|
@@ -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)
|
||||||
|
Reference in New Issue
Block a user