Compare commits

...

20 Commits

Author SHA1 Message Date
Min RK
e7eb674a89 0.9.0b3 2018-05-23 16:30:07 +02:00
Min RK
b232633100 Merge pull request #1894 from minrk/db-rollback
Rollback database sessions on SQLAlchemy errors
2018-05-23 16:09:51 +02:00
Carol Willing
6abd19c149 Merge pull request #1911 from minrk/log-classes
log Authenticator and Spawner classes at startup
2018-05-22 11:50:59 -07:00
Min RK
0aa0ff8db7 Merge pull request #1912 from minrk/double-slash
Fix login redirect checking for `//` urls
2018-05-22 15:56:29 +02:00
Min RK
a907429fd4 more test cases for login redirects 2018-05-22 15:40:27 +02:00
Min RK
598b550a67 fix query/hash login redirect handling 2018-05-22 15:40:14 +02:00
Min RK
92bb442494 more robust checking for login redirects outside jupyterhub 2018-05-22 15:40:00 +02:00
Min RK
2d41f6223e log Authenticator and Spawner classes at startup
for better diagnostics
2018-05-22 13:52:41 +02:00
Min RK
791dd5fb9f Merge pull request #1895 from minrk/oauth-commits
avoid creating one huge transaction cleaning up oauth clients
2018-05-22 13:37:56 +02:00
Carol Willing
9a0ccf4c98 Merge pull request #1910 from minrk/ip-typo
default bind url should be on all ips
2018-05-22 01:26:35 -07:00
Min RK
ad2abc5771 default bind url should be on all ips
preserves jupyterhub default behavior

typo introduced in new bind_url config
2018-05-22 09:55:01 +02:00
Min RK
2d99b3943f enable pessimistic connection handling
from the sqlalchemy docs

checks if a connection is valid via `SELECT 1` prior to using it.

Since we have long-running connections, this helps us survive database restarts, disconnects, etc.
2018-05-21 22:14:11 +02:00
Min RK
a358132f95 remove --rm from docker-db.sh
for easier stop/start testing
2018-05-21 22:12:30 +02:00
Tim Head
09cd37feee Merge pull request #1896 from thoralf-gutierrez/fix-typos-in-config
Fix typos in auth config documentation
2018-05-16 22:37:51 +02:00
Thoralf Gutierrez
0f3610e81d Fix typos in auth config documentation 2018-05-16 10:58:02 -07:00
Min RK
3f97c438e2 avoid creating one huge transaction cleaning up oauth clients 2018-05-15 16:33:50 +02:00
Min RK
42351201d2 back to dev 2018-05-15 16:32:24 +02:00
Min RK
63f3d8b621 catch database errors in update_last_activity 2018-05-15 13:53:05 +02:00
Min RK
47d6e841fd cache get_current_user result
avoids raising an error rendering templates, etc.
2018-05-15 13:49:38 +02:00
Min RK
e3bb09fabe rollback database session on db errors
ensures reconnect will occur when database connection is lost
2018-05-15 13:49:14 +02:00
9 changed files with 183 additions and 34 deletions

View File

@@ -8,7 +8,7 @@ export MYSQL_HOST=127.0.0.1
export MYSQL_TCP_PORT=${MYSQL_TCP_PORT:-13306}
export PGHOST=127.0.0.1
NAME="hub-test-$DB"
DOCKER_RUN="docker run --rm -d --name $NAME"
DOCKER_RUN="docker run -d --name $NAME"
docker rm -f "$NAME" 2>/dev/null || true
@@ -47,4 +47,4 @@ Set these environment variables:
export MYSQL_HOST=127.0.0.1
export MYSQL_TCP_PORT=$MYSQL_TCP_PORT
export PGHOST=127.0.0.1
"
"

View File

@@ -7,8 +7,8 @@ version_info = (
0,
9,
0,
'b2', # release
# 'dev', # dev
"b3", # release (b1, rc1)
# "dev", # dev
)
# pep 440 version: no dot before beta/rc, but before .dev

View File

@@ -6,6 +6,7 @@ import json
from http.client import responses
from sqlalchemy.exc import SQLAlchemyError
from tornado import web
from .. import orm
@@ -87,6 +88,10 @@ class APIHandler(BaseHandler):
if reason:
status_message = reason
if exception and isinstance(exception, SQLAlchemyError):
self.log.warning("Rolling back session due to database error %s", exception)
self.db.rollback()
self.set_header('Content-Type', 'application/json')
# allow setting headers from exceptions
# since exception handler clears headers

View File

@@ -27,7 +27,7 @@ if sys.version_info[:2] < (3, 3):
from dateutil.parser import parse as parse_date
from jinja2 import Environment, FileSystemLoader, PrefixLoader, ChoiceLoader
from sqlalchemy.exc import OperationalError
from sqlalchemy.exc import OperationalError, SQLAlchemyError
from tornado.httpclient import AsyncHTTPClient
import tornado.httpserver
@@ -358,7 +358,7 @@ class JupyterHub(Application):
self.bind_url = bind_url
bind_url = Unicode(
"http://127.0.0.1:8000",
"http://:8000",
help="""The public facing URL of the whole JupyterHub application.
This is the address on which the proxy will bind.
@@ -1493,10 +1493,16 @@ class JupyterHub(Application):
oauth_client_ids.add(spawner.oauth_client_id)
client_store = self.oauth_provider.client_authenticator.client_store
for oauth_client in self.db.query(orm.OAuthClient):
for i, oauth_client in enumerate(self.db.query(orm.OAuthClient)):
if oauth_client.identifier not in oauth_client_ids:
self.log.warning("Deleting OAuth client %s", oauth_client.identifier)
self.db.delete(oauth_client)
# Some deployments that create temporary users may have left *lots*
# of entries here.
# Don't try to delete them all in one transaction,
# commit at most 100 deletions at a time.
if i % 100 == 0:
self.db.commit()
self.db.commit()
def init_proxy(self):
@@ -1621,6 +1627,33 @@ class JupyterHub(Application):
cfg.JupyterHub.merge(cfg.JupyterHubApp)
self.update_config(cfg)
self.write_pid_file()
def _log_cls(name, cls):
"""Log a configured class
Logs the class and version (if found) of Authenticator
and Spawner
"""
# try to guess the version from the top-level module
# this will work often enough to be useful.
# no need to be perfect.
if cls.__module__:
mod = sys.modules.get(cls.__module__.split('.')[0])
version = getattr(mod, '__version__', '')
if version:
version = '-{}'.format(version)
else:
version = ''
self.log.info(
"Using %s: %s.%s%s",
name,
cls.__module__ or '',
cls.__name__,
version,
)
_log_cls("Authenticator", self.authenticator_class)
_log_cls("Spawner", self.spawner_class)
self.init_pycurl()
self.init_secrets()
self.init_db()
@@ -1759,7 +1792,13 @@ class JupyterHub(Application):
self.statsd.gauge('users.running', users_count)
self.statsd.gauge('users.active', active_users_count)
self.db.commit()
try:
self.db.commit()
except SQLAlchemyError:
self.log.exception("Rolling back session due to database error")
self.db.rollback()
return
await self.proxy.check_routes(self.users, self._service_map, routes)
async def start(self):

View File

@@ -49,7 +49,7 @@ class Authenticator(LoggingConfigurable):
Encrypting auth_state requires the cryptography package.
Additionally, the JUPYTERHUB_CRYPTO_KEY envirionment variable must
Additionally, the JUPYTERHUB_CRYPT_KEY environment variable must
contain one (or more, separated by ;) 32B encryption keys.
These can be either base64 or hex-encoded.

View File

@@ -15,6 +15,7 @@ import uuid
from jinja2 import TemplateNotFound
from sqlalchemy.exc import SQLAlchemyError
from tornado.log import app_log
from tornado.httputil import url_concat, HTTPHeaders
from tornado.ioloop import IOLoop
@@ -264,10 +265,17 @@ class BaseHandler(RequestHandler):
def get_current_user(self):
"""get current username"""
user = self.get_current_user_token()
if user is not None:
return user
return self.get_current_user_cookie()
if not hasattr(self, '_jupyterhub_user'):
try:
user = self.get_current_user_token()
if user is None:
user = self.get_current_user_cookie()
self._jupyterhub_user = user
except Exception:
# don't let errors here raise more than once
self._jupyterhub_user = None
raise
return self._jupyterhub_user
def find_user(self, name):
"""Get a user by name
@@ -417,10 +425,20 @@ class BaseHandler(RequestHandler):
- else: /hub/home
"""
next_url = self.get_argument('next', default='')
if (next_url + '/').startswith('%s://%s/' % (self.request.protocol, self.request.host)):
if (next_url + '/').startswith(
(
'%s://%s/' % (self.request.protocol, self.request.host),
'//%s/' % self.request.host,
)
):
# treat absolute URLs for our host as absolute paths:
next_url = urlparse(next_url).path
if next_url and not next_url.startswith('/'):
parsed = urlparse(next_url)
next_url = parsed.path
if parsed.query:
next_url = next_url + '?' + parsed.query
if parsed.hash:
next_url = next_url + '#' + parsed.hash
if next_url and (urlparse(next_url).netloc or not next_url.startswith('/')):
self.log.warning("Disallowing redirect outside JupyterHub: %r", next_url)
next_url = ''
if next_url and next_url.startswith(url_path_join(self.base_url, 'user/')):
@@ -794,6 +812,10 @@ class BaseHandler(RequestHandler):
if reason:
message = reasons.get(reason, reason)
if exception and isinstance(exception, SQLAlchemyError):
self.log.warning("Rolling back session due to database error %s", exception)
self.db.rollback()
# build template namespace
ns = dict(
status_code=status_code,

View File

@@ -14,7 +14,7 @@ from tornado.log import app_log
from sqlalchemy.types import TypeDecorator, TEXT, LargeBinary
from sqlalchemy import (
create_engine, event, inspect, or_,
create_engine, event, exc, inspect, or_, select,
Column, Integer, ForeignKey, Unicode, Boolean,
DateTime, Enum, Table,
)
@@ -575,7 +575,7 @@ def _expire_relationship(target, relationship_prop):
def _notify_deleted_relationships(session, obj):
"""Expire relationships when an object becomes deleted
Needed for
Needed to keep relationships up to date.
"""
mapper = inspect(obj).mapper
for prop in mapper.relationships:
@@ -583,6 +583,52 @@ def _notify_deleted_relationships(session, obj):
_expire_relationship(obj, prop)
def register_ping_connection(engine):
"""Check connections before using them.
Avoids database errors when using stale connections.
From SQLAlchemy docs on pessimistic disconnect handling:
https://docs.sqlalchemy.org/en/rel_1_1/core/pooling.html#disconnect-handling-pessimistic
"""
@event.listens_for(engine, "engine_connect")
def ping_connection(connection, branch):
if branch:
# "branch" refers to a sub-connection of a connection,
# we don't want to bother pinging on these.
return
# turn off "close with result". This flag is only used with
# "connectionless" execution, otherwise will be False in any case
save_should_close_with_result = connection.should_close_with_result
connection.should_close_with_result = False
try:
# run a SELECT 1. use a core select() so that
# the SELECT of a scalar value without a table is
# appropriately formatted for the backend
connection.scalar(select([1]))
except exc.DBAPIError as err:
# catch SQLAlchemy's DBAPIError, which is a wrapper
# for the DBAPI's exception. It includes a .connection_invalidated
# attribute which specifies if this connection is a "disconnect"
# condition, which is based on inspection of the original exception
# by the dialect in use.
if err.connection_invalidated:
app_log.error("Database connection error, attempting to reconnect: %s", err)
# run the same SELECT again - the connection will re-validate
# itself and establish a new connection. The disconnect detection
# here also causes the whole connection pool to be invalidated
# so that all stale connections are discarded.
connection.scalar(select([1]))
else:
raise
finally:
# restore "close with result"
connection.should_close_with_result = save_should_close_with_result
def check_db_revision(engine):
"""Check the JupyterHub database revision
@@ -661,10 +707,12 @@ def mysql_large_prefix_check(engine):
else:
return False
def add_row_format(base):
for t in base.metadata.tables.values():
t.dialect_kwargs['mysql_ROW_FORMAT'] = 'DYNAMIC'
def new_session_factory(url="sqlite:///:memory:",
reset=False,
expire_on_commit=False,
@@ -684,6 +732,9 @@ def new_session_factory(url="sqlite:///:memory:",
kwargs.setdefault('poolclass', StaticPool)
engine = create_engine(url, **kwargs)
# enable pessimistic disconnect handling
register_ping_connection(engine)
if reset:
Base.metadata.drop_all(engine)

View File

@@ -242,6 +242,16 @@ def test_resume_spawners(tmpdir, request):
{'bind_url': 'http://0.0.0.0:12345/sub'},
{'base_url': '/sub/'},
),
(
# no config, test defaults
{},
{
'base_url': '/',
'bind_url': 'http://:8000',
'ip': '',
'port': 8000,
},
),
]
)
def test_url_config(hub_config, expected):

View File

@@ -3,6 +3,7 @@
from urllib.parse import urlencode, urlparse
from tornado import gen
from tornado.httputil import url_concat
from ..handlers import BaseHandler
from ..utils import url_path_join as ujoin
@@ -366,29 +367,50 @@ def test_login_strip(app):
assert called_with == [form_data]
@pytest.mark.parametrize(
'running, next_url, location',
[
# default URL if next not specified, for both running and not
(True, '', ''),
(False, '', ''),
# next_url is respected
(False, '/hub/admin', '/hub/admin'),
(False, '/user/other', '/hub/user/other'),
(False, '/absolute', '/absolute'),
(False, '/has?query#andhash', '/has?query#andhash'),
# next_url outside is not allowed
(False, 'https://other.domain', ''),
(False, 'ftp://other.domain', ''),
(False, '//other.domain', ''),
]
)
@pytest.mark.gen_test
def test_login_redirect(app):
def test_login_redirect(app, running, next_url, location):
cookies = yield app.login_user('river')
user = app.users['river']
# no next_url, server running
yield user.spawn()
r = yield get_page('login', app, cookies=cookies, allow_redirects=False)
r.raise_for_status()
assert r.status_code == 302
assert '/user/river' in r.headers['Location']
if location:
location = ujoin(app.base_url, location)
else:
# use default url
location = user.url
# no next_url, server not running
yield user.stop()
r = yield get_page('login', app, cookies=cookies, allow_redirects=False)
r.raise_for_status()
assert r.status_code == 302
assert '/user/river' in r.headers['Location']
url = 'login'
if next_url:
if '//' not in next_url:
next_url = ujoin(app.base_url, next_url, '')
url = url_concat(url, dict(next=next_url))
# next URL given, use it
r = yield get_page('login?next=/hub/admin', app, cookies=cookies, allow_redirects=False)
if running and not user.active:
# ensure running
yield user.spawn()
elif user.active and not running:
# ensure not running
yield user.stop()
r = yield get_page(url, app, cookies=cookies, allow_redirects=False)
r.raise_for_status()
assert r.status_code == 302
assert r.headers['Location'].endswith('/hub/admin')
assert location == r.headers['Location']
@pytest.mark.gen_test