mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-15 14:03:02 +00:00
add API endpoint for services
This commit is contained in:
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
64
jupyterhub/apihandlers/services.py
Normal file
64
jupyterhub/apihandlers/services.py
Normal 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),
|
||||
]
|
@@ -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)
|
||||
|
@@ -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(
|
||||
|
@@ -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
|
||||
|
@@ -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')
|
||||
@@ -615,7 +632,7 @@ def test_group_create_delete(app):
|
||||
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')
|
||||
|
@@ -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
|
||||
|
Reference in New Issue
Block a user