add API endpoint for services

This commit is contained in:
Min RK
2016-09-02 15:19:45 +02:00
parent 26b00578a1
commit 5ad618bfc1
9 changed files with 231 additions and 53 deletions

View File

@@ -312,6 +312,30 @@ paths:
responses: responses:
'200': '200':
description: The users have been removed from the group description: The users have been removed from the group
/services:
get:
summary: List services
responses:
'200':
description: The service list
schema:
type: array
items:
$ref: '#/definitions/Service'
/services/{name}:
get:
summary: Get a service by name
parameters:
- name: name
description: service name
in: path
required: true
type: string
responses:
'200':
description: The Service model
schema:
$ref: '#/definitions/Service'
/proxy: /proxy:
get: get:
summary: Get the proxy's routing table summary: Get the proxy's routing table
@@ -436,3 +460,26 @@ definitions:
description: The names of users who are members of this group description: The names of users who are members of this group
items: items:
type: string type: string
Service:
type: object
properties:
name:
type: string
description: The service's name
admin:
type: boolean
description: Whether the service is an admin
url:
type: string
description: The internal url where the service is running
prefix:
type: string
description: The proxied URL prefix to the service's url
pid:
type: number
description: The PID of the service process (if managed)
command:
type: array
description: The command used to start the service (if managed)
items:
type: string

View File

@@ -54,7 +54,7 @@ JUPYTERHUB_SERVICE_NAME: the name of the service ('cull-idle' above)
JUPYTERHUB_API_TOKEN: API token assigned to the service JUPYTERHUB_API_TOKEN: API token assigned to the service
JUPYTERHUB_API_URL: URL for the JupyterHub API (http://127.0.0.1:8080/hub/api) JUPYTERHUB_API_URL: URL for the JupyterHub API (http://127.0.0.1:8080/hub/api)
JUPYTERHUB_BASE_URL: Base URL of the Hub (https://mydomain[:port]/) JUPYTERHUB_BASE_URL: Base URL of the Hub (https://mydomain[:port]/)
JUPYTERHUB_SERVICE_PATH: Base path of this service (/service/cull-idle/) JUPYTERHUB_SERVICE_PREFIX: URL path prefix of this service (/services/cull-idle/)
``` ```
## External services ## External services

View File

@@ -1,11 +1,6 @@
from .base import * from .base import *
from .auth import * from . import auth, hub, proxy, users, groups, services
from .hub import *
from .proxy import *
from .users import *
from .groups import *
from . import auth, hub, proxy, users
default_handlers = [] default_handlers = []
for mod in (auth, hub, proxy, users, groups): for mod in (auth, hub, proxy, users, groups, services):
default_handlers.extend(mod.default_handlers) default_handlers.extend(mod.default_handlers)

View File

@@ -0,0 +1,64 @@
"""Service handlers
Currently GET-only, no actions can be taken to modify services.
"""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import json
from tornado import web
from .. import orm
from ..utils import admin_only
from .base import APIHandler
def service_model(service):
"""Produce the model for a service"""
return {
'name': service.name,
'admin': service.admin,
'url': service.url,
'prefix': service.server.base_url if service.server else '',
'command': service.command,
'pid': service.proc.pid if service.proc else 0,
}
class ServiceListAPIHandler(APIHandler):
@admin_only
def get(self):
data = {name: service_model(service) for name, service in self.services.items()}
self.write(json.dumps(data))
def admin_or_self(method):
"""Decorator for restricting access to either the target service or admin"""
def m(self, name):
current = self.get_current_user()
if current is None:
raise web.HTTPError(403)
if not current.admin:
# not admin, maybe self
if not isinstance(current, orm.Service):
raise web.HTTPError(403)
if current.name != name:
raise web.HTTPError(403)
# raise 404 if not found
if name not in self.services:
raise web.HTTPError(404)
return method(self, name)
return m
class ServiceAPIHandler(APIHandler):
@admin_or_self
def get(self, name):
service = self.services[name]
self.write(json.dumps(service_model(service)))
default_handlers = [
(r"/api/services", ServiceListAPIHandler),
(r"/api/services/([^/]+)", ServiceAPIHandler),
]

View File

@@ -968,7 +968,7 @@ class JupyterHub(Application):
self._add_tokens(self.api_tokens, kind='user') self._add_tokens(self.api_tokens, kind='user')
def init_services(self): def init_services(self):
self._service_map = {} self._service_map.clear()
if self.domain: if self.domain:
domain = 'services.' + self.domain domain = 'services.' + self.domain
parsed = urlparse(self.subdomain_host) parsed = urlparse(self.subdomain_host)

View File

@@ -181,10 +181,10 @@ class Service(LoggingConfigurable):
domain = Unicode() domain = Unicode()
host = Unicode() host = Unicode()
proc = Any()
# handles on globals: # handles on globals:
proxy = Any() proxy = Any()
base_url = Unicode() base_url = Unicode()
db = Any() db = Any()
orm = Any() orm = Any()
@@ -225,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.server.base_url env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url
env['JUPYTERHUB_SERVICE_URL'] = self.url env['JUPYTERHUB_SERVICE_URL'] = self.url
self.spawner = _ServiceSpawner( self.spawner = _ServiceSpawner(

View File

@@ -5,14 +5,19 @@
import logging import logging
from getpass import getuser from getpass import getuser
from subprocess import TimeoutExpired
from pytest import fixture import time
from unittest import mock
from pytest import fixture, yield_fixture, raises
from tornado import ioloop from tornado import ioloop
from .. import orm from .. import orm
from ..utils import random_port
from .mocking import MockHub from .mocking import MockHub
from .test_services import mockservice_cmd
import jupyterhub.services.service
# global db session object # global db session object
_db = None _db = None
@@ -53,3 +58,34 @@ def app(request):
app.stop() app.stop()
request.addfinalizer(fin) request.addfinalizer(fin)
return app return app
# mock services for testing.
# Shorter intervals, etc.
class MockServiceSpawner(jupyterhub.services.service._ServiceSpawner):
poll_interval = 1
@yield_fixture
def mockservice(request, app):
name = 'mock-service'
with mock.patch.object(jupyterhub.services.service, '_ServiceSpawner', MockServiceSpawner):
app.services = [{
'name': name,
'command': mockservice_cmd,
'url': 'http://127.0.0.1:%i' % random_port(),
'admin': True,
}]
app.init_services()
app.io_loop.add_callback(app.proxy.add_all_services, app._service_map)
assert name in app._service_map
service = app._service_map[name]
app.io_loop.add_callback(service.start)
request.addfinalizer(service.stop)
for i in range(20):
if not getattr(service, 'proc', False):
time.sleep(0.2)
# ensure process finishes starting
with raises(TimeoutExpired):
service.proc.wait(1)
yield service

View File

@@ -6,6 +6,7 @@ from queue import Queue
import sys import sys
from urllib.parse import urlparse, quote from urllib.parse import urlparse, quote
from pytest import mark
import requests import requests
from tornado import gen from tornado import gen
@@ -155,6 +156,7 @@ def test_referer_check(app, io_loop):
# user API tests # user API tests
@mark.user
def test_get_users(app): def test_get_users(app):
db = app.db db = app.db
r = api_request(app, 'users') r = api_request(app, 'users')
@@ -185,6 +187,8 @@ def test_get_users(app):
) )
assert r.status_code == 403 assert r.status_code == 403
@mark.user
def test_add_user(app): def test_add_user(app):
db = app.db db = app.db
name = 'newuser' name = 'newuser'
@@ -196,6 +200,7 @@ def test_add_user(app):
assert not user.admin assert not user.admin
@mark.user
def test_get_user(app): def test_get_user(app):
name = 'user' name = 'user'
r = api_request(app, 'users', name) r = api_request(app, 'users', name)
@@ -211,6 +216,7 @@ def test_get_user(app):
} }
@mark.user
def test_add_multi_user_bad(app): def test_add_multi_user_bad(app):
r = api_request(app, 'users', method='post') r = api_request(app, 'users', method='post')
assert r.status_code == 400 assert r.status_code == 400
@@ -220,6 +226,7 @@ def test_add_multi_user_bad(app):
assert r.status_code == 400 assert r.status_code == 400
@mark.user
def test_add_multi_user_invalid(app): def test_add_multi_user_invalid(app):
app.authenticator.username_pattern = r'w.*' app.authenticator.username_pattern = r'w.*'
r = api_request(app, 'users', method='post', r = api_request(app, 'users', method='post',
@@ -230,6 +237,7 @@ def test_add_multi_user_invalid(app):
assert r.json()['message'] == 'Invalid usernames: andrew, tara' assert r.json()['message'] == 'Invalid usernames: andrew, tara'
@mark.user
def test_add_multi_user(app): def test_add_multi_user(app):
db = app.db db = app.db
names = ['a', 'b'] names = ['a', 'b']
@@ -265,6 +273,7 @@ def test_add_multi_user(app):
assert r_names == ['ab'] assert r_names == ['ab']
@mark.user
def test_add_multi_user_admin(app): def test_add_multi_user_admin(app):
db = app.db db = app.db
names = ['c', 'd'] names = ['c', 'd']
@@ -283,6 +292,7 @@ def test_add_multi_user_admin(app):
assert user.admin assert user.admin
@mark.user
def test_add_user_bad(app): def test_add_user_bad(app):
db = app.db db = app.db
name = 'dne_newuser' name = 'dne_newuser'
@@ -291,6 +301,8 @@ def test_add_user_bad(app):
user = find_user(db, name) user = find_user(db, name)
assert user is None assert user is None
@mark.user
def test_add_admin(app): def test_add_admin(app):
db = app.db db = app.db
name = 'newadmin' name = 'newadmin'
@@ -304,6 +316,7 @@ def test_add_admin(app):
assert user.admin assert user.admin
@mark.user
def test_delete_user(app): def test_delete_user(app):
db = app.db db = app.db
mal = add_user(db, name='mal') mal = add_user(db, name='mal')
@@ -311,6 +324,7 @@ def test_delete_user(app):
assert r.status_code == 204 assert r.status_code == 204
@mark.user
def test_make_admin(app): def test_make_admin(app):
db = app.db db = app.db
name = 'admin2' name = 'admin2'
@@ -542,6 +556,7 @@ def test_bad_get_token(app):
# group API tests # group API tests
@mark.group
def test_groups_list(app): def test_groups_list(app):
r = api_request(app, 'groups') r = api_request(app, 'groups')
r.raise_for_status() r.raise_for_status()
@@ -562,6 +577,7 @@ def test_groups_list(app):
}] }]
@mark.group
def test_group_get(app): def test_group_get(app):
group = orm.Group.find(app.db, name='alphaflight') group = orm.Group.find(app.db, name='alphaflight')
user = add_user(app.db, app=app, name='sasquatch') user = add_user(app.db, app=app, name='sasquatch')
@@ -580,6 +596,7 @@ def test_group_get(app):
} }
@mark.group
def test_group_create_delete(app): def test_group_create_delete(app):
db = app.db db = app.db
r = api_request(app, 'groups/runaways', method='delete') r = api_request(app, 'groups/runaways', method='delete')
@@ -613,9 +630,9 @@ def test_group_create_delete(app):
# delete nonexistant gives 404 # delete nonexistant gives 404
r = api_request(app, 'groups/omegaflight', method='delete') r = api_request(app, 'groups/omegaflight', method='delete')
assert r.status_code == 404 assert r.status_code == 404
@mark.group
def test_group_add_users(app): def test_group_add_users(app):
db = app.db db = app.db
# must specify users # must specify users
@@ -637,6 +654,7 @@ def test_group_add_users(app):
assert sorted([ u.name for u in group.users ]) == sorted(names) assert sorted([ u.name for u in group.users ]) == sorted(names)
@mark.group
def test_group_delete_users(app): def test_group_delete_users(app):
db = app.db db = app.db
# must specify users # must specify users
@@ -659,6 +677,61 @@ def test_group_delete_users(app):
assert sorted([ u.name for u in group.users ]) == sorted(names[2:]) assert sorted([ u.name for u in group.users ]) == sorted(names[2:])
# service API
@mark.services
def test_get_services(app, mockservice):
db = app.db
r = api_request(app, 'services')
r.raise_for_status()
assert r.status_code == 200
services = r.json()
assert services == {
'mock-service': {
'name': 'mock-service',
'admin': True,
'command': mockservice.command,
'pid': mockservice.proc.pid,
'prefix': mockservice.server.base_url,
'url': mockservice.url,
}
}
r = api_request(app, 'services',
headers=auth_header(db, 'user'),
)
assert r.status_code == 403
@mark.services
def test_get_service(app, mockservice):
db = app.db
r = api_request(app, 'services/%s' % mockservice.name)
r.raise_for_status()
assert r.status_code == 200
service = r.json()
assert service == {
'name': 'mock-service',
'admin': True,
'command': mockservice.command,
'pid': mockservice.proc.pid,
'prefix': mockservice.server.base_url,
'url': mockservice.url,
}
r = api_request(app, 'services/%s' % mockservice.name,
headers={
'Authorization': 'token %s' % mockservice.api_token
}
)
r.raise_for_status()
r = api_request(app, 'services/%s' % mockservice.name,
headers=auth_header(db, 'user'),
)
assert r.status_code == 403
def test_root_api(app): def test_root_api(app):
base_url = app.hub.server.url base_url = app.hub.server.url
url = ujoin(base_url, 'api') url = ujoin(base_url, 'api')

View File

@@ -3,25 +3,17 @@
from binascii import hexlify from binascii import hexlify
from contextlib import contextmanager from contextlib import contextmanager
import os import os
from subprocess import Popen, TimeoutExpired from subprocess import Popen
import sys import sys
from threading import Event from threading import Event
import time import time
try:
from unittest import mock
except ImportError:
import mock
from urllib.parse import unquote
import pytest
import requests import requests
from tornado import gen from tornado import gen
from tornado.ioloop import IOLoop from tornado.ioloop import IOLoop
import jupyterhub.services.service
from .mocking import public_url from .mocking import public_url
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
here = os.path.dirname(os.path.abspath(__file__)) here = os.path.dirname(os.path.abspath(__file__))
@@ -30,6 +22,7 @@ mockservice_cmd = [sys.executable, mockservice_py]
from ..utils import random_port from ..utils import random_port
@contextmanager @contextmanager
def external_service(app, name='mockservice'): def external_service(app, name='mockservice'):
env = { env = {
@@ -46,36 +39,6 @@ def external_service(app, name='mockservice'):
p.terminate() p.terminate()
# mock services for testing.
# Shorter intervals, etc.
class MockServiceSpawner(jupyterhub.services.service._ServiceSpawner):
poll_interval = 1
@pytest.yield_fixture
def mockservice(request, app):
name = 'mock-service'
with mock.patch.object(jupyterhub.services.service, '_ServiceSpawner', MockServiceSpawner):
app.services = [{
'name': name,
'command': mockservice_cmd,
'url': 'http://127.0.0.1:%i' % random_port(),
'admin': True,
}]
app.init_services()
app.io_loop.add_callback(app.proxy.add_all_services, app._service_map)
assert name in app._service_map
service = app._service_map[name]
app.io_loop.add_callback(service.start)
request.addfinalizer(service.stop)
for i in range(20):
if not getattr(service, 'proc', False):
time.sleep(0.2)
# ensure process finishes starting
with pytest.raises(TimeoutExpired):
service.proc.wait(1)
yield service
def test_managed_service(app, mockservice): def test_managed_service(app, mockservice):
service = mockservice service = mockservice
proc = service.proc proc = service.proc