diff --git a/bower.json b/bower.json new file mode 100644 index 0000000..ce78039 --- /dev/null +++ b/bower.json @@ -0,0 +1,11 @@ +{ + "name": "jupyterhub-deps", + "version": "0.0.0", + "dependencies": { + "bootstrap": "components/bootstrap#~3.1", + "font-awesome": "components/font-awesome#~4.1", + "jquery": "components/jquery#~2.0", + "moment": "~2.7", + "requirejs": "~2.1" + } +} diff --git a/everware/__init__.py b/everware/__init__.py new file mode 100644 index 0000000..49f7ba1 --- /dev/null +++ b/everware/__init__.py @@ -0,0 +1,5 @@ + +from .spawner import * +from .authenticator import * +from .homehandler import * + diff --git a/everware/authenticator.py b/everware/authenticator.py new file mode 100644 index 0000000..72e505f --- /dev/null +++ b/everware/authenticator.py @@ -0,0 +1,299 @@ +""" +Custom Authenticator to use GitHub OAuth with JupyterHub + +Most of the code c/o Kyle Kelley (@rgbkrk) +""" + + +import json +import os +import urllib + +from tornado.auth import OAuth2Mixin +from tornado.escape import url_escape +from tornado import gen, web + +from tornado.httputil import url_concat +from tornado.httpclient import HTTPRequest, AsyncHTTPClient + +from jupyterhub.handlers import BaseHandler +from jupyterhub.auth import Authenticator, LocalAuthenticator +from jupyterhub.utils import url_path_join + +from traitlets import Unicode, Set + + +class GitHubMixin(OAuth2Mixin): + _OAUTH_AUTHORIZE_URL = "https://github.com/login/oauth/authorize" + _OAUTH_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" + + +class BitbucketMixin(OAuth2Mixin): + _OAUTH_AUTHORIZE_URL = "https://bitbucket.org/site/oauth2/authorize" + _OAUTH_ACCESS_TOKEN_URL = "https://bitbucket.org/site/oauth2/access_token" + + +class WelcomeHandler(BaseHandler): + """Render the login page.""" + + def _render(self, login_error=None, username=None): + return self.render_template('login.html', + next=url_escape(self.get_argument('next', default='')), + username=username, + login_error=login_error, + ) + + def get(self): + next_url = self.get_argument('next', '') + if not next_url.startswith('/'): + # disallow non-absolute next URLs (e.g. full URLs) + next_url = '' + user = self.get_current_user() + if user: + if not next_url: + if user.running: + next_url = user.server.base_url + else: + next_url = self.hub.server.base_url + # set new login cookie + # because single-user cookie may have been cleared or incorrect + #self.set_login_cookie(self.get_current_user()) + self.redirect('/oauth_login', permanent=False) + else: + self.finish(self._render()) + +class OAuthLoginHandler(BaseHandler): + + def get(self): + guess_uri = '{proto}://{host}{path}'.format( + proto=self.request.protocol, + host=self.request.host, + path=url_path_join( + self.hub.server.base_url, + 'oauth_callback' + ) + ) + + redirect_uri = self.authenticator.oauth_callback_url or guess_uri + self.log.info('oauth redirect: %r', redirect_uri) + + self.authorize_redirect( + redirect_uri=redirect_uri, + client_id=self.authenticator.client_id, + scope=[], + response_type='code') + + +class GitHubLoginHandler(OAuthLoginHandler, GitHubMixin): + pass + + +class BitbucketLoginHandler(OAuthLoginHandler, BitbucketMixin): + pass + + +class GitHubOAuthHandler(BaseHandler): + @gen.coroutine + def get(self): + # TODO: Check if state argument needs to be checked + username = yield self.authenticator.authenticate(self) + if username: + user = self.user_from_username(username) + self.set_login_cookie(user) + self.redirect(self.hub.server.base_url) + else: + # todo: custom error page? + raise web.HTTPError(403) + + +class BitbucketOAuthHandler(GitHubOAuthHandler): + pass + + +class GitHubOAuthenticator(Authenticator): + + login_service = "GitHub" + oauth_callback_url = Unicode('', config=True) + client_id = Unicode(os.environ.get('GITHUB_CLIENT_ID', ''), + config=True) + client_secret = Unicode(os.environ.get('GITHUB_CLIENT_SECRET', ''), + config=True) + + def login_url(self, base_url): + return url_path_join(base_url, 'login') + + def get_handlers(self, app): + return [ + (r'/login', WelcomeHandler), + (r'/oauth_login', GitHubLoginHandler), + (r'/oauth_callback', GitHubOAuthHandler), + ] + + @gen.coroutine + def authenticate(self, handler): + code = handler.get_argument("code", False) + if not code: + raise web.HTTPError(400, "oauth callback made without a token") + # TODO: Configure the curl_httpclient for tornado + http_client = AsyncHTTPClient() + + # Exchange the OAuth code for a GitHub Access Token + # + # See: https://developer.github.com/v3/oauth/ + + # GitHub specifies a POST request yet requires URL parameters + params = dict( + client_id=self.client_id, + client_secret=self.client_secret, + code=code + ) + + url = url_concat("https://github.com/login/oauth/access_token", + params) + + req = HTTPRequest(url, + method="POST", + headers={"Accept": "application/json"}, + body='' # Body is required for a POST... + ) + + resp = yield http_client.fetch(req) + resp_json = json.loads(resp.body.decode('utf8', 'replace')) + + access_token = resp_json['access_token'] + + # Determine who the logged in user is + headers={"Accept": "application/json", + "User-Agent": "JupyterHub", + "Authorization": "token {}".format(access_token) + } + req = HTTPRequest("https://api.github.com/user", + method="GET", + headers=headers + ) + resp = yield http_client.fetch(req) + resp_json = json.loads(resp.body.decode('utf8', 'replace')) + + username = resp_json["login"] + if self.whitelist and username not in self.whitelist: + username = None + raise gen.Return(username) + + +class BitbucketOAuthenticator(Authenticator): + + login_service = "Bitbucket" + oauth_callback_url = Unicode(os.environ.get('OAUTH_CALLBACK_URL', ''), + config=True) + client_id = Unicode(os.environ.get('BITBUCKET_CLIENT_ID', ''), + config=True) + client_secret = Unicode(os.environ.get('BITBUCKET_CLIENT_SECRET', ''), + config=True) + team_whitelist = Set( + config=True, + help="Automatically whitelist members of selected teams", + ) + + def login_url(self, base_url): + return url_path_join(base_url, 'oauth_login') + + def get_handlers(self, app): + return [ + (r'/oauth_login', BitbucketLoginHandler), + (r'/oauth_callback', BitbucketOAuthHandler), + ] + + @gen.coroutine + def authenticate(self, handler): + code = handler.get_argument("code", False) + if not code: + raise web.HTTPError(400, "oauth callback made without a token") + # TODO: Configure the curl_httpclient for tornado + http_client = AsyncHTTPClient() + + params = dict( + client_id=self.client_id, + client_secret=self.client_secret, + grant_type="authorization_code", + code=code, + redirect_uri=self.oauth_callback_url + ) + + url = url_concat( + "https://bitbucket.org/site/oauth2/access_token", params) + self.log.info(url) + + bb_header = {"Content-Type": + "application/x-www-form-urlencoded;charset=utf-8"} + req = HTTPRequest(url, + method="POST", + auth_username=self.client_id, + auth_password=self.client_secret, + body=urllib.parse.urlencode(params).encode('utf-8'), + headers=bb_header + ) + + resp = yield http_client.fetch(req) + resp_json = json.loads(resp.body.decode('utf8', 'replace')) + + access_token = resp_json['access_token'] + + # Determine who the logged in user is + headers = {"Accept": "application/json", + "User-Agent": "JupyterHub", + "Authorization": "Bearer {}".format(access_token) + } + req = HTTPRequest("https://api.bitbucket.org/2.0/user", + method="GET", + headers=headers + ) + resp = yield http_client.fetch(req) + resp_json = json.loads(resp.body.decode('utf8', 'replace')) + + username = resp_json["username"] + whitelisted = yield self.check_whitelist(username, headers) + if not whitelisted: + username = None + return username + + def check_whitelist(self, username, headers): + if self.team_whitelist: + return self._check_group_whitelist(username, headers) + else: + return self._check_user_whitelist(username) + + @gen.coroutine + def _check_user_whitelist(self, user): + return (not self.whitelist) or (user in self.whitelist) + + @gen.coroutine + def _check_group_whitelist(self, username, headers): + http_client = AsyncHTTPClient() + + # We verify the team membership by calling teams endpoint. + # Re-use the headers, change the request. + next_page = url_concat("https://api.bitbucket.org/2.0/teams", + {'role': 'member'}) + user_teams = set() + while next_page: + req = HTTPRequest(next_page, method="GET", headers=headers) + resp = yield http_client.fetch(req) + resp_json = json.loads(resp.body.decode('utf8', 'replace')) + next_page = resp_json.get('next', None) + + user_teams |= \ + set([entry["username"] for entry in resp_json["values"]]) + return len(self.team_whitelist & user_teams) > 0 + + +class LocalGitHubOAuthenticator(LocalAuthenticator, GitHubOAuthenticator): + + """A version that mixes in local system user creation""" + pass + + +class LocalBitbucketOAuthenticator(LocalAuthenticator, + BitbucketOAuthenticator): + + """A version that mixes in local system user creation""" + pass diff --git a/everware/homehandler.py b/everware/homehandler.py new file mode 100644 index 0000000..c0f9672 --- /dev/null +++ b/everware/homehandler.py @@ -0,0 +1,37 @@ + +from tornado import gen +from jupyterhub.handlers.pages import web, BaseHandler + +class HomeHandler(BaseHandler): + """Render the user's home page.""" + + @web.authenticated + def get(self): + html = self.render_template('home.html', + user=self.get_current_user(), + ) + self.finish(html) + + @gen.coroutine + def post(self): + data = {} + for arg in self.request.arguments: + data[arg] = self.get_argument(arg) + + repo_url = data['repourl'] + user = self.get_current_user() + user.last_repo_url = repo_url + + already_running = False + if user.spawner: + status = yield user.spawner.poll() + already_running = (status == None) + if not already_running: + yield self.spawn_single_user(user) + + user.last_repo_url = repo_url + self.db.commit() + + self.redirect(self.hub.server.base_url) + + diff --git a/everware/spawner.py b/everware/spawner.py new file mode 100644 index 0000000..c9a48b1 --- /dev/null +++ b/everware/spawner.py @@ -0,0 +1,110 @@ +import pwd +from tempfile import mkdtemp +from datetime import timedelta + +from dockerspawner import DockerSpawner +from textwrap import dedent +from traitlets import ( + Integer, + Unicode, +) +from tornado import gen + +from escapism import escape + +import git + + +class CustomDockerSpawner(DockerSpawner): + def __init__(self, **kwargs): + self.user = kwargs['user'] + self.repo_url = self.user.last_repo_url + self.repo_sha = kwargs.get('last_commit', '') + super(CustomDockerSpawner, self).__init__(**kwargs) + + _git_executor = None + @property + def git_executor(self): + """single global git executor""" + cls = self.__class__ + if cls._git_executor is None: + cls._git_executor = ThreadPoolExecutor(1) + return cls._git_executor + + _git_client = None + @property + def git_client(self): + """single global git client instance""" + cls = self.__class__ + if cls._git_client is None: + cls._git_client = git.Git() + return cls._git_client + + def _git(self, method, *args, **kwargs): + """wrapper for calling git methods + + to be passed to ThreadPoolExecutor + """ + m = getattr(self.git_client, method) + return m(*args, **kwargs) + + def git(self, method, *args, **kwargs): + """Call a git method in a background thread + + returns a Future + """ + return self.executor.submit(self._git, method, *args, **kwargs) + + _escaped_repo_url = None + + @property + def escaped_repo_url(self): + if self._escaped_repo_url is None: + trans = str.maketrans(':/-.', "____") + self._escaped_repo_url = self.repo_url.translate(trans) + return self._escaped_repo_url + + @property + def container_name(self): + return "{}-{}-{}-{}".format(self.container_prefix, + self.escaped_name, + self.escaped_repo_url, + self.repo_sha) + + @gen.coroutine + def start(self, image=None): + """start the single-user server in a docker container""" + tmp_dir = mkdtemp(suffix='-everware') + yield self.git('clone', self.repo_url, tmp_dir) + # is this blocking? + # use the username, git repo URL and HEAD commit sha to derive + # the image name + repo = git.Repo(tmp_dir) + self.repo_sha = repo.rev_parse("HEAD") + + image_name = "everware/{}-{}-{}".format(self.user.name, + self.escaped_repo_url, + self.repo_sha) + + self.log.debug("Building image {}".format(image_name)) + build_log = yield gen.with_timeout(timedelta(30), + self.docker('build', + path=tmp_dir, + tag='bla', #image_name, + rm=True)) + self.log.debug("".join(str(line) for line in build_log)) + self.log.info("Built docker image {}".format(image_name)) + + images = yield self.docker('images', image_name) + self.log.debug(images) + + yield super(CustomDockerSpawner, self).start( + image='bla' #image_name + ) + + def _env_default(self): + env = super(CustomDockerSpawner, self)._env_default() + + env.update({'JPY_GITHUBURL': self.repo_url}) + + return env diff --git a/package.json b/package.json new file mode 100644 index 0000000..a6d7702 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "jupyterhub-deps", + "version": "0.0.0", + "description": "JupyterHub nodejs dependencies", + "author": "Jupyter Developers", + "license": "BSD", + "repository": { + "type": "git", + "url": "https://github.com/jupyter/jupyterhub.git" + }, + "devDependencies": { + "bower": "*", + "less": "~2", + "less-plugin-clean-css": "*" + } +} diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..4064dfe --- /dev/null +++ b/setup.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python +# coding: utf-8 + +from __future__ import print_function + +import os +import sys +import shutil +from subprocess import check_call + +v = sys.version_info +if v[:2] < (3,3): + error = "ERROR: Jupyter Hub requires Python version 3.3 or above." + print(error, file=sys.stderr) + sys.exit(1) + +def mtime(path): + """shorthand for mtime""" + return os.stat(path).st_mtime + + +if os.name in ('nt', 'dos'): + error = "ERROR: Windows is not supported" + print(error, file=sys.stderr) + +# At least we're on the python version we need, move on. + +from distutils.core import setup + +pjoin = os.path.join +here = os.path.abspath(os.path.dirname(__file__)) + +from distutils.cmd import Command +from distutils.command.build_py import build_py +from distutils.command.sdist import sdist + +npm_path = ':'.join([ + pjoin(here, 'node_modules', '.bin'), + os.environ.get("PATH", os.defpath), +]) + +here = os.path.abspath(os.path.dirname(__file__)) +share = pjoin(here, 'share') +static = pjoin(share, 'static') + +class BaseCommand(Command): + """Dumb empty command because Command needs subclasses to override too much""" + user_options = [] + + def initialize_options(self): + pass + + def finalize_options(self): + pass + + def get_inputs(self): + return [] + + def get_outputs(self): + return [] + +class Bower(BaseCommand): + description = "fetch static client-side components with bower" + + user_options = [] + bower_dir = pjoin(static, 'components') + node_modules = pjoin(here, 'node_modules') + + def should_run(self): + if not os.path.exists(self.bower_dir): + return True + return mtime(self.bower_dir) < mtime(pjoin(here, 'bower.json')) + + def should_run_npm(self): + if not shutil.which('npm'): + print("npm unavailable", file=sys.stderr) + return False + if not os.path.exists(self.node_modules): + return True + return mtime(self.node_modules) < mtime(pjoin(here, 'package.json')) + + def run(self): + if not self.should_run(): + print("bower dependencies up to date") + return + + if self.should_run_npm(): + print("installing build dependencies with npm") + check_call(['npm', 'install'], cwd=here) + os.utime(self.node_modules) + + env = os.environ.copy() + env['PATH'] = npm_path + + try: + check_call( + ['bower', 'install', '--allow-root', '--config.interactive=false'], + cwd=here, + env=env, + ) + except OSError as e: + print("Failed to run bower: %s" % e, file=sys.stderr) + print("You can install js dependencies with `npm install`", file=sys.stderr) + raise + os.utime(self.bower_dir) + # update data-files in case this created new files + self.distribution.data_files = get_data_files() + + +class CSS(BaseCommand): + description = "compile CSS from LESS" + + def should_run(self): + """Does less need to run?""" + # from IPython.html.tasks.py + + css_targets = [pjoin(static, 'css', 'style.min.css')] + css_maps = [t + '.map' for t in css_targets] + targets = css_targets + css_maps + if not all(os.path.exists(t) for t in targets): + # some generated files don't exist + return True + earliest_target = sorted(mtime(t) for t in targets)[0] + + # check if any .less files are newer than the generated targets + for (dirpath, dirnames, filenames) in os.walk(static): + for f in filenames: + if f.endswith('.less'): + path = pjoin(static, dirpath, f) + timestamp = mtime(path) + if timestamp > earliest_target: + return True + + return False + + def run(self): + if not self.should_run(): + print("CSS up-to-date") + return + + self.run_command('js') + + style_less = pjoin(static, 'less', 'style.less') + style_css = pjoin(static, 'css', 'style.min.css') + sourcemap = style_css + '.map' + + env = os.environ.copy() + env['PATH'] = npm_path + try: + check_call([ + 'lessc', '--clean-css', + '--source-map-basepath={}'.format(static), + '--source-map={}'.format(sourcemap), + '--source-map-rootpath=../', + style_less, style_css, + ], cwd=here, env=env) + except OSError as e: + print("Failed to run lessc: %s" % e, file=sys.stderr) + print("You can install js dependencies with `npm install`", file=sys.stderr) + raise + # update data-files in case this created new files + self.distribution.data_files = get_data_files() + +def get_data_files(): + """Get data files in share/jupyter""" + + data_files = [] + ntrim = len(here) + 1 + + for (d, dirs, filenames) in os.walk(static): + data_files.append(( + d[ntrim:], + [ pjoin(d, f) for f in filenames ] + )) + return data_files + + +setup_args = dict( + name = 'everware', + packages = ['everware'], + version = '0.0.0', + description = """Everware""", + long_description = "", + author = "", + author_email = "", + url = "", + license = "BSD", + platforms = "Linux, Mac OS X", + keywords = ['Interactive', 'Interpreter', 'Shell', 'Web'], + classifiers = [ + 'Intended Audience :: Developers', + 'Intended Audience :: System Administrators', + 'Intended Audience :: Science/Research', + 'License :: OSI Approved :: BSD License', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + ], +) + +setup_args['cmdclass'] = {'js': Bower, 'css': CSS} + +# setuptools requirements +if 'setuptools' in sys.modules: + setup_args['install_requires'] = install_requires = [] + with open('requirements.txt') as f: + for line in f.readlines(): + req = line.strip() + if not req or req.startswith(('-e', '#')): + continue + install_requires.append(req) + + +def main(): + setup(**setup_args) + +if __name__ == '__main__': + main() diff --git a/share/static/html/error.html b/share/static/html/error.html new file mode 100644 index 0000000..c8fef45 --- /dev/null +++ b/share/static/html/error.html @@ -0,0 +1,23 @@ +{% extends "page.html" %} + +{% block login_widget %} +{% endblock %} + +{% block main %} + +
+ {{message}} +
+ {% endif %} + {% endblock error_detail %} +Run your notebooks in the cloud
+Run your notebooks in the cloud
+Your server is starting up.
+You will be redirected automatically when it's ready for you.
+ refresh +