sync with master

This commit is contained in:
Min RK
2021-04-22 14:14:02 +02:00
21 changed files with 339 additions and 119 deletions

View File

@@ -1,32 +0,0 @@
# Python CircleCI 2.0 configuration file
# Updating CircleCI configuration from v1 to v2
# Check https://circleci.com/docs/2.0/language-python/ for more details
#
version: 2
jobs:
build:
machine: true
steps:
- checkout
- run:
name: build images
command: |
docker build -t jupyterhub/jupyterhub .
docker build -t jupyterhub/jupyterhub-onbuild onbuild
docker build -t jupyterhub/jupyterhub:alpine -f dockerfiles/Dockerfile.alpine .
docker build -t jupyterhub/singleuser singleuser
- run:
name: smoke test jupyterhub
command: |
docker run --rm -it jupyterhub/jupyterhub jupyterhub --help
- run:
name: verify static files
command: |
docker run --rm -it -v $PWD/dockerfiles:/io jupyterhub/jupyterhub python3 /io/test.py
# Tell CircleCI to use this workflow when it builds the site
workflows:
version: 2
default:
jobs:
- build

View File

@@ -66,3 +66,120 @@ jobs:
run: | run: |
pip install twine pip install twine
twine upload --skip-existing dist/* twine upload --skip-existing dist/*
publish-docker:
runs-on: ubuntu-20.04
services:
# So that we can test this in PRs/branches
local-registry:
image: registry:2
ports:
- 5000:5000
steps:
- name: Should we push this image to a public registry?
run: |
if [ "${{ startsWith(github.ref, 'refs/tags/') || (github.ref == 'refs/heads/master') }}" = "true" ]; then
# Empty => Docker Hub
echo "REGISTRY=" >> $GITHUB_ENV
else
echo "REGISTRY=localhost:5000/" >> $GITHUB_ENV
fi
- uses: actions/checkout@v2
# Setup docker to build for multiple platforms, see:
# https://github.com/docker/build-push-action/tree/v2.4.0#usage
# https://github.com/docker/build-push-action/blob/v2.4.0/docs/advanced/multi-platform.md
- name: Set up QEMU (for docker buildx)
uses: docker/setup-qemu-action@25f0500ff22e406f7191a2a8ba8cda16901ca018 # associated tag: v1.0.2
- name: Set up Docker Buildx (for multi-arch builds)
uses: docker/setup-buildx-action@2a4b53665e15ce7d7049afb11ff1f70ff1610609 # associated tag: v1.1.2
with:
# Allows pushing to registry on localhost:5000
driver-opts: network=host
- name: Setup push rights to Docker Hub
# This was setup by...
# 1. Creating a Docker Hub service account "jupyterhubbot"
# 2. Creating a access token for the service account specific to this
# repository: https://hub.docker.com/settings/security
# 3. Making the account part of the "bots" team, and granting that team
# permissions to push to the relevant images:
# https://hub.docker.com/orgs/jupyterhub/teams/bots/permissions
# 4. Registering the username and token as a secret for this repo:
# https://github.com/jupyterhub/jupyterhub/settings/secrets/actions
if: env.REGISTRY != 'localhost:5000/'
run: |
docker login -u "${{ secrets.DOCKER_USERNAME }}" -p "${{ secrets.DOCKERHUB_TOKEN }}"
# https://github.com/jupyterhub/action-major-minor-tag-calculator
# If this is a tagged build this will return additional parent tags.
# E.g. 1.2.3 is expanded to Docker tags
# [{prefix}:1.2.3, {prefix}:1.2, {prefix}:1, {prefix}:latest] unless
# this is a backported tag in which case the newer tags aren't updated.
# For branches this will return the branch name.
# If GITHUB_TOKEN isn't available (e.g. in PRs) returns no tags [].
- name: Get list of jupyterhub tags
id: jupyterhubtags
uses: jupyterhub/action-major-minor-tag-calculator@v1
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub:"
defaultTag: "${{ env.REGISTRY }}jupyterhub/jupyterhub:noref"
- name: Build and push jupyterhub
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
# tags parameter must be a string input so convert `gettags` JSON
# array into a comma separated list of tags
tags: ${{ join(fromJson(steps.jupyterhubtags.outputs.tags)) }}
# jupyterhub-onbuild
- name: Get list of jupyterhub-onbuild tags
id: onbuildtags
uses: jupyterhub/action-major-minor-tag-calculator@v1
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-onbuild:"
defaultTag: "${{ env.REGISTRY }}jupyterhub/jupyterhub-onbuild:noref"
- name: Build and push jupyterhub-onbuild
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
with:
build-args: |
BASE_IMAGE=${{ fromJson(steps.jupyterhubtags.outputs.tags)[0] }}
context: onbuild
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ join(fromJson(steps.onbuildtags.outputs.tags)) }}
# jupyterhub-demo
- name: Get list of jupyterhub-demo tags
id: demotags
uses: jupyterhub/action-major-minor-tag-calculator@v1
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-demo:"
defaultTag: "${{ env.REGISTRY }}jupyterhub/jupyterhub-demo:noref"
- name: Build and push jupyterhub-demo
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
with:
build-args: |
BASE_IMAGE=${{ fromJson(steps.onbuildtags.outputs.tags)[0] }}
context: demo-image
# linux/arm64 currently fails:
# ERROR: Could not build wheels for argon2-cffi which use PEP 517 and cannot be installed directly
# ERROR: executor failed running [/bin/sh -c python3 -m pip install notebook]: exit code: 1
platforms: linux/amd64
push: true
tags: ${{ join(fromJson(steps.demotags.outputs.tags)) }}

View File

@@ -222,3 +222,25 @@ jobs:
- name: Submit codecov report - name: Submit codecov report
run: | run: |
codecov codecov
docker-build:
runs-on: ubuntu-20.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v2
- name: build images
run: |
docker build -t jupyterhub/jupyterhub .
docker build -t jupyterhub/jupyterhub-onbuild onbuild
docker build -t jupyterhub/jupyterhub:alpine -f dockerfiles/Dockerfile.alpine .
docker build -t jupyterhub/singleuser singleuser
- name: smoke test jupyterhub
run: |
docker run --rm -t jupyterhub/jupyterhub jupyterhub --help
- name: verify static files
run: |
docker run --rm -t -v $PWD/dockerfiles:/io jupyterhub/jupyterhub python3 /io/test.py

View File

@@ -21,7 +21,7 @@
# your jupyterhub_config.py will be added automatically # your jupyterhub_config.py will be added automatically
# from your docker directory. # from your docker directory.
ARG BASE_IMAGE=ubuntu:focal-20200729@sha256:6f2fb2f9fb5582f8b587837afd6ea8f37d8d1d9e41168c90f410a6ef15fa8ce5 ARG BASE_IMAGE=ubuntu:focal-20200729
FROM $BASE_IMAGE AS builder FROM $BASE_IMAGE AS builder
USER root USER root

View File

@@ -3,7 +3,7 @@ swagger: "2.0"
info: info:
title: JupyterHub title: JupyterHub
description: The REST API for JupyterHub description: The REST API for JupyterHub
version: 1.2.0dev version: 1.4.0
license: license:
name: BSD-3-Clause name: BSD-3-Clause
schemes: [http, https] schemes: [http, https]

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +0,0 @@
# Docker Cloud build hooks
These are the hooks

View File

@@ -1,7 +0,0 @@
#!/bin/bash
set -exuo pipefail
# build jupyterhub-onbuild image
docker build --build-arg BASE_IMAGE=$DOCKER_REPO:$DOCKER_TAG -t ${DOCKER_REPO}-onbuild:$DOCKER_TAG onbuild
# build jupyterhub-demo image
docker build --build-arg BASE_IMAGE=${DOCKER_REPO}-onbuild:$DOCKER_TAG -t ${DOCKER_REPO}-demo:$DOCKER_TAG demo-image

View File

@@ -1,42 +0,0 @@
#!/bin/bash
set -exuo pipefail
export ONBUILD=${DOCKER_REPO}-onbuild
export DEMO=${DOCKER_REPO}-demo
export REPOS="${DOCKER_REPO} ${ONBUILD} ${DEMO}"
# push ONBUILD image
docker push $ONBUILD:$DOCKER_TAG
docker push $DEMO:$DOCKER_TAG
function get_hub_version() {
rm -f hub_version
docker run --rm -v $PWD:/version -u $(id -u) -i $DOCKER_REPO:$DOCKER_TAG sh -c 'jupyterhub --version > /version/hub_version'
hub_xyz=$(cat hub_version)
split=( ${hub_xyz//./ } )
hub_xy="${split[0]}.${split[1]}"
# add .dev on hub_xy so it's 1.0.dev
if [[ ! -z "${split[3]:-}" ]]; then
hub_xy="${hub_xy}.${split[3]}"
latest=0
else
latest=1
fi
}
get_hub_version
for repo in ${REPOS}; do
# when building master, push 0.9.0.dev as well
docker tag $repo:$DOCKER_TAG $repo:$hub_xyz
docker push $repo:$hub_xyz
# when building 0.9.x, push 0.9 as well
docker tag $repo:$DOCKER_TAG $repo:$hub_xy
docker push $repo:$hub_xy
# if building a stable release, tag latest as well
if [[ "$latest" == "1" ]]; then
docker tag $repo:$DOCKER_TAG $repo:latest
docker push $repo:latest
fi
done

View File

@@ -885,6 +885,66 @@ class JupyterHub(Application):
def _hub_prefix_default(self): def _hub_prefix_default(self):
return url_path_join(self.base_url, '/hub/') return url_path_join(self.base_url, '/hub/')
hub_routespec = Unicode(
"/",
help="""
The routing prefix for the Hub itself.
Override to send only a subset of traffic to the Hub.
Default is to use the Hub as the default route for all requests.
This is necessary for normal jupyterhub operation,
as the Hub must receive requests for e.g. `/user/:name`
when the user's server is not running.
However, some deployments using only the JupyterHub API
may want to handle these events themselves,
in which case they can register their own default target with the proxy
and set e.g. `hub_routespec = /hub/` to serve only the hub's own pages, or even `/hub/api/` for api-only operation.
Note: hub_routespec must include the base_url, if any.
.. versionadded:: 1.4
""",
).tag(config=True)
@default("hub_routespec")
def _default_hub_routespec(self):
# Default routespec for the Hub is the *app* base url
# not the hub URL, so the Hub receives requests for non-running servers
# use `/` with host-based routing so the Hub
# gets requests for all hosts
if self.subdomain_host:
routespec = '/'
else:
routespec = self.base_url
return routespec
@validate("hub_routespec")
def _validate_hub_routespec(self, proposal):
"""ensure leading/trailing / on custom routespec prefix
- trailing '/' always required
- leading '/' required unless using subdomains
"""
routespec = proposal.value
if not routespec.endswith("/"):
routespec = routespec + "/"
if not self.subdomain_host and not routespec.startswith("/"):
routespec = "/" + routespec
return routespec
@observe("hub_routespec")
def _hub_routespec_changed(self, change):
if change.new == change.old:
return
routespec = change.new
if routespec not in {'/', self.base_url}:
self.log.warning(
f"Using custom route for Hub: {routespec}."
" Requests for not-running servers may not be handled."
)
@observe('base_url') @observe('base_url')
def _update_hub_prefix(self, change): def _update_hub_prefix(self, change):
"""add base URL to hub prefix""" """add base URL to hub prefix"""
@@ -1718,6 +1778,7 @@ class JupyterHub(Application):
"""Load the Hub URL config""" """Load the Hub URL config"""
hub_args = dict( hub_args = dict(
base_url=self.hub_prefix, base_url=self.hub_prefix,
routespec=self.hub_routespec,
public_host=self.subdomain_host, public_host=self.subdomain_host,
certfile=self.internal_ssl_cert, certfile=self.internal_ssl_cert,
keyfile=self.internal_ssl_key, keyfile=self.internal_ssl_key,
@@ -1733,17 +1794,15 @@ class JupyterHub(Application):
hub_args['ip'] = self.hub_ip hub_args['ip'] = self.hub_ip
hub_args['port'] = self.hub_port hub_args['port'] = self.hub_port
# routespec for the Hub is the *app* base url self.hub = Hub(**hub_args)
# not the hub URL, so it receives requests for non-running servers
# use `/` with host-based routing so the Hub
# gets requests for all hosts
host = ''
if self.subdomain_host:
routespec = '/'
else:
routespec = self.base_url
self.hub = Hub(routespec=routespec, **hub_args) if not self.subdomain_host:
api_prefix = url_path_join(self.hub.base_url, "api/")
if not api_prefix.startswith(self.hub.routespec):
self.log.warning(
f"Hub API prefix {api_prefix} not on prefix {self.hub.routespec}. "
"The Hub may not receive any API requests from outside."
)
if self.hub_connect_ip: if self.hub_connect_ip:
self.hub.connect_ip = self.hub_connect_ip self.hub.connect_ip = self.hub_connect_ip

View File

@@ -26,10 +26,9 @@ def write_alembic_ini(alembic_ini='alembic.ini', db_url='sqlite:///jupyterhub.sq
Parameters Parameters
---------- ----------
alembic_ini : str
alembic_ini: str
path to the alembic.ini file that should be written. path to the alembic.ini file that should be written.
db_url: str db_url : str
The SQLAlchemy database url, e.g. `sqlite:///jupyterhub.sqlite`. The SQLAlchemy database url, e.g. `sqlite:///jupyterhub.sqlite`.
""" """
with open(ALEMBIC_INI_TEMPLATE_PATH) as f: with open(ALEMBIC_INI_TEMPLATE_PATH) as f:
@@ -58,13 +57,11 @@ def _temp_alembic_ini(db_url):
Parameters Parameters
---------- ----------
db_url : str
db_url: str
The SQLAlchemy database url, e.g. `sqlite:///jupyterhub.sqlite`. The SQLAlchemy database url, e.g. `sqlite:///jupyterhub.sqlite`.
Returns Returns
------- -------
alembic_ini: str alembic_ini: str
The path to the temporary alembic.ini that we have created. The path to the temporary alembic.ini that we have created.
This file will be cleaned up on exit from the context manager. This file will be cleaned up on exit from the context manager.

View File

@@ -1,3 +1,4 @@
"""Handlers for serving prometheus metrics"""
from prometheus_client import CONTENT_TYPE_LATEST from prometheus_client import CONTENT_TYPE_LATEST
from prometheus_client import generate_latest from prometheus_client import generate_latest
from prometheus_client import REGISTRY from prometheus_client import REGISTRY
@@ -17,4 +18,7 @@ class MetricsHandler(BaseHandler):
self.write(generate_latest(REGISTRY)) self.write(generate_latest(REGISTRY))
default_handlers = [(r'/metrics$', MetricsHandler)] default_handlers = [
(r'/metrics$', MetricsHandler),
(r'/api/metrics$', MetricsHandler),
]

View File

@@ -676,4 +676,5 @@ default_handlers = [
(r'/token', TokenPageHandler), (r'/token', TokenPageHandler),
(r'/error/(\d+)', ProxyErrorHandler), (r'/error/(\d+)', ProxyErrorHandler),
(r'/health$', HealthCheckHandler), (r'/health$', HealthCheckHandler),
(r'/api/health$', HealthCheckHandler),
] ]

View File

@@ -32,6 +32,7 @@ from tornado.ioloop import PeriodicCallback
from traitlets import Any from traitlets import Any
from traitlets import Bool from traitlets import Bool
from traitlets import default from traitlets import default
from traitlets import Dict
from traitlets import Instance from traitlets import Instance
from traitlets import Integer from traitlets import Integer
from traitlets import observe from traitlets import observe
@@ -112,6 +113,26 @@ class Proxy(LoggingConfigurable):
""", """,
) )
extra_routes = Dict(
{},
config=True,
help="""
Additional routes to be maintained in the proxy.
A dictionary with a route specification as key, and
a URL as target. The hub will ensure this route is present
in the proxy.
If the hub is running in host based mode (with
JupyterHub.subdomain_host set), the routespec *must*
have a domain component (example.com/my-url/). If the
hub is not running in host based mode, the routespec
*must not* have a domain component (/my-url/).
Helpful when the hub is running in API-only mode.
""",
)
def start(self): def start(self):
"""Start the proxy. """Start the proxy.
@@ -330,7 +351,7 @@ class Proxy(LoggingConfigurable):
route = routes[self.app.hub.routespec] route = routes[self.app.hub.routespec]
if route['target'] != hub.host: if route['target'] != hub.host:
self.log.warning( self.log.warning(
"Updating default route %s%s", route['target'], hub.host "Updating Hub route %s%s", route['target'], hub.host
) )
futures.append(self.add_hub_route(hub)) futures.append(self.add_hub_route(hub))
@@ -384,6 +405,11 @@ class Proxy(LoggingConfigurable):
) )
futures.append(self.add_service(service)) futures.append(self.add_service(service))
# Add extra routes we've been configured for
for routespec, url in self.extra_routes.items():
good_routes.add(routespec)
futures.append(self.add_route(routespec, url, {'extra': True}))
# Now delete the routes that shouldn't be there # Now delete the routes that shouldn't be there
for routespec in routes: for routespec in routes:
if routespec not in good_routes: if routespec not in good_routes:
@@ -396,7 +422,7 @@ class Proxy(LoggingConfigurable):
def add_hub_route(self, hub): def add_hub_route(self, hub):
"""Add the default route for the Hub""" """Add the default route for the Hub"""
self.log.info("Adding default route for Hub: %s => %s", hub.routespec, hub.host) self.log.info("Adding route for Hub: %s => %s", hub.routespec, hub.host)
return self.add_route(hub.routespec, self.hub.host, {'hub': True}) return self.add_route(hub.routespec, self.hub.host, {'hub': True})
async def restore_routes(self): async def restore_routes(self):

View File

@@ -668,12 +668,15 @@ class HubOAuth(HubAuth):
Parameters Parameters
---------- ----------
handler (RequestHandler): A tornado RequestHandler handler : RequestHandler
next_url (str): The page to redirect to on successful login A tornado RequestHandler
next_url : str
The page to redirect to on successful login
Returns Returns
------- -------
state (str): The OAuth state that has been stored in the cookie (url safe, base64-encoded) state : str
The OAuth state that has been stored in the cookie (url safe, base64-encoded)
""" """
extra_state = {} extra_state = {}
if handler.get_cookie(self.state_cookie_name): if handler.get_cookie(self.state_cookie_name):
@@ -710,7 +713,8 @@ class HubOAuth(HubAuth):
Parameters Parameters
---------- ----------
next_url (str): The URL of the page to redirect to on successful login. next_url : str
The URL of the page to redirect to on successful login.
Returns Returns
------- -------

View File

@@ -729,7 +729,7 @@ class Spawner(LoggingConfigurable):
Returns Returns
------- -------
state: dict state: dict
a JSONable dict of state a JSONable dict of state
""" """
state = {} state = {}
return state return state

View File

@@ -1,5 +1,6 @@
"""Test the JupyterHub entry point""" """Test the JupyterHub entry point"""
import binascii import binascii
import logging
import os import os
import re import re
import sys import sys
@@ -329,3 +330,41 @@ def test_url_config(hub_config, expected):
# validate additional properties # validate additional properties
for key, value in expected.items(): for key, value in expected.items():
assert getattr(app, key) == value assert getattr(app, key) == value
@pytest.mark.parametrize(
"base_url, hub_routespec, expected_routespec, should_warn, bad_prefix",
[
(None, None, "/", False, False),
("/", "/", "/", False, False),
("/base", "/base", "/base/", False, False),
("/", "/hub", "/hub/", True, False),
(None, "hub/api", "/hub/api/", True, False),
("/base", "/hub/", "/hub/", True, True),
(None, "/hub/api/health", "/hub/api/health/", True, True),
],
)
def test_hub_routespec(
base_url, hub_routespec, expected_routespec, should_warn, bad_prefix, caplog
):
cfg = Config()
if base_url:
cfg.JupyterHub.base_url = base_url
if hub_routespec:
cfg.JupyterHub.hub_routespec = hub_routespec
with caplog.at_level(logging.WARNING):
app = JupyterHub(config=cfg, log=logging.getLogger())
app.init_hub()
hub = app.hub
assert hub.routespec == expected_routespec
if should_warn:
assert "custom route for Hub" in caplog.text
assert hub_routespec in caplog.text
else:
assert "custom route for Hub" not in caplog.text
if bad_prefix:
assert "may not receive" in caplog.text
else:
assert "may not receive" not in caplog.text

View File

@@ -195,6 +195,25 @@ async def test_check_routes(app, username, disable_check_routes):
assert before == after assert before == after
async def test_extra_routes(app):
proxy = app.proxy
# When using host_routing, it's up to the admin to
# provide routespecs that have a domain in them.
# We don't explicitly validate that here.
if app.subdomain_host:
route_spec = 'example.com/test-extra-routes/'
else:
route_spec = '/test-extra-routes/'
target = 'http://localhost:9999/test'
proxy.extra_routes = {route_spec: target}
await proxy.check_routes(app.users, app._service_map)
routes = await app.proxy.get_all_routes()
assert route_spec in routes
assert routes[route_spec]['target'] == target
@pytest.mark.parametrize( @pytest.mark.parametrize(
"routespec", "routespec",
[ [

View File

@@ -72,9 +72,8 @@ def check_db_locks(func):
The decorator relies on an instance of JupyterHubApp being the first The decorator relies on an instance of JupyterHubApp being the first
argument to the decorated function. argument to the decorated function.
Example Examples
------- --------
@check_db_locks @check_db_locks
def api_request(app, *api_path, **kwargs): def api_request(app, *api_path, **kwargs):

View File

@@ -2,7 +2,7 @@
If you base a Dockerfile on this image: If you base a Dockerfile on this image:
FROM jupyterhub/jupyterhub-onbuild:0.6 FROM jupyterhub/jupyterhub-onbuild:1.4.0
... ...
then your `jupyterhub_config.py` adjacent to your Dockerfile will be loaded into the image and used by JupyterHub. then your `jupyterhub_config.py` adjacent to your Dockerfile will be loaded into the image and used by JupyterHub.

View File

@@ -10,6 +10,7 @@
{% block login %} {% block login %}
<div id="login-main" class="container"> <div id="login-main" class="container">
{% block login_container %}
{% if custom_html %} {% if custom_html %}
{{ custom_html | safe }} {{ custom_html | safe }}
{% elif login_service %} {% elif login_service %}
@@ -83,6 +84,7 @@
</div> </div>
</form> </form>
{% endif %} {% endif %}
{% endblock login_container %}
</div> </div>
{% endblock login %} {% endblock login %}