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:
'200':
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:
get:
summary: Get the proxy's routing table
@@ -436,3 +460,26 @@ definitions:
description: The names of users who are members of this group
items:
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_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_SERVICE_PATH: Base path of this service (/service/cull-idle/)
JUPYTERHUB_SERVICE_PREFIX: URL path prefix of this service (/services/cull-idle/)
```
## External services

View File

@@ -1,11 +1,6 @@
from .base import *
from .auth import *
from .hub import *
from .proxy import *
from .users import *
from .groups import *
from . import auth, hub, proxy, users
from . import auth, hub, proxy, users, groups, services
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)

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')
def init_services(self):
self._service_map = {}
self._service_map.clear()
if self.domain:
domain = 'services.' + self.domain
parsed = urlparse(self.subdomain_host)

View File

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

View File

@@ -5,14 +5,19 @@
import logging
from getpass import getuser
from pytest import fixture
from subprocess import TimeoutExpired
import time
from unittest import mock
from pytest import fixture, yield_fixture, raises
from tornado import ioloop
from .. import orm
from ..utils import random_port
from .mocking import MockHub
from .test_services import mockservice_cmd
import jupyterhub.services.service
# global db session object
_db = None
@@ -53,3 +58,34 @@ def app(request):
app.stop()
request.addfinalizer(fin)
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
from urllib.parse import urlparse, quote
from pytest import mark
import requests
from tornado import gen
@@ -155,6 +156,7 @@ def test_referer_check(app, io_loop):
# user API tests
@mark.user
def test_get_users(app):
db = app.db
r = api_request(app, 'users')
@@ -185,6 +187,8 @@ def test_get_users(app):
)
assert r.status_code == 403
@mark.user
def test_add_user(app):
db = app.db
name = 'newuser'
@@ -196,6 +200,7 @@ def test_add_user(app):
assert not user.admin
@mark.user
def test_get_user(app):
name = 'user'
r = api_request(app, 'users', name)
@@ -211,6 +216,7 @@ def test_get_user(app):
}
@mark.user
def test_add_multi_user_bad(app):
r = api_request(app, 'users', method='post')
assert r.status_code == 400
@@ -220,6 +226,7 @@ def test_add_multi_user_bad(app):
assert r.status_code == 400
@mark.user
def test_add_multi_user_invalid(app):
app.authenticator.username_pattern = r'w.*'
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'
@mark.user
def test_add_multi_user(app):
db = app.db
names = ['a', 'b']
@@ -265,6 +273,7 @@ def test_add_multi_user(app):
assert r_names == ['ab']
@mark.user
def test_add_multi_user_admin(app):
db = app.db
names = ['c', 'd']
@@ -283,6 +292,7 @@ def test_add_multi_user_admin(app):
assert user.admin
@mark.user
def test_add_user_bad(app):
db = app.db
name = 'dne_newuser'
@@ -291,6 +301,8 @@ def test_add_user_bad(app):
user = find_user(db, name)
assert user is None
@mark.user
def test_add_admin(app):
db = app.db
name = 'newadmin'
@@ -304,6 +316,7 @@ def test_add_admin(app):
assert user.admin
@mark.user
def test_delete_user(app):
db = app.db
mal = add_user(db, name='mal')
@@ -311,6 +324,7 @@ def test_delete_user(app):
assert r.status_code == 204
@mark.user
def test_make_admin(app):
db = app.db
name = 'admin2'
@@ -542,6 +556,7 @@ def test_bad_get_token(app):
# group API tests
@mark.group
def test_groups_list(app):
r = api_request(app, 'groups')
r.raise_for_status()
@@ -562,6 +577,7 @@ def test_groups_list(app):
}]
@mark.group
def test_group_get(app):
group = orm.Group.find(app.db, name='alphaflight')
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):
db = app.db
r = api_request(app, 'groups/runaways', method='delete')
@@ -613,9 +630,9 @@ def test_group_create_delete(app):
# delete nonexistant gives 404
r = api_request(app, 'groups/omegaflight', method='delete')
assert r.status_code == 404
@mark.group
def test_group_add_users(app):
db = app.db
# must specify users
@@ -637,6 +654,7 @@ def test_group_add_users(app):
assert sorted([ u.name for u in group.users ]) == sorted(names)
@mark.group
def test_group_delete_users(app):
db = app.db
# 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:])
# 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):
base_url = app.hub.server.url
url = ujoin(base_url, 'api')

View File

@@ -3,25 +3,17 @@
from binascii import hexlify
from contextlib import contextmanager
import os
from subprocess import Popen, TimeoutExpired
from subprocess import Popen
import sys
from threading import Event
import time
try:
from unittest import mock
except ImportError:
import mock
from urllib.parse import unquote
import pytest
import requests
from tornado import gen
from tornado.ioloop import IOLoop
import jupyterhub.services.service
from .mocking import public_url
from .test_pages import get_page
from ..utils import url_path_join, wait_for_http_server
here = os.path.dirname(os.path.abspath(__file__))
@@ -30,6 +22,7 @@ mockservice_cmd = [sys.executable, mockservice_py]
from ..utils import random_port
@contextmanager
def external_service(app, name='mockservice'):
env = {
@@ -46,36 +39,6 @@ def external_service(app, name='mockservice'):
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):
service = mockservice
proc = service.proc