include stopped servers in user model

opt-in, behind ?include_stopped_servers

Adds `stopped` field to server model to more easily select stopped servers
This commit is contained in:
Min RK
2022-05-25 14:19:46 +02:00
parent 28b11d2165
commit 6afa0d6311
5 changed files with 152 additions and 20 deletions

View File

@@ -139,6 +139,16 @@ paths:
If unspecified, use api_page_default_limit. If unspecified, use api_page_default_limit.
schema: schema:
type: number type: number
- name: include_stopped_servers
in: query
description: |
Include stopped servers in user model(s).
Added in JupyterHub 3.0.
Allows retrieval of information about stopped servers,
such as activity and state fields.
schema:
type: boolean
allowEmptyValue: true
responses: responses:
200: 200:
description: The Hub's user list description: The Hub's user list
@@ -1160,7 +1170,11 @@ components:
format: date-time format: date-time
servers: servers:
type: array type: array
description: The active servers for this user. description: |
The servers for this user.
By default: only includes _active_ servers.
Changed in 3.0: if `?include_stopped_servers` parameter is specified,
stopped servers will be included as well.
items: items:
$ref: "#/components/schemas/Server" $ref: "#/components/schemas/Server"
auth_state: auth_state:
@@ -1182,6 +1196,15 @@ components:
description: | description: |
Whether the server is ready for traffic. Whether the server is ready for traffic.
Will always be false when any transition is pending. Will always be false when any transition is pending.
stopped:
type: boolean
description: |
Whether the server is stopped. Added in JupyterHub 3.0,
and only useful when using the `?include_stopped_servers`
request parameter.
Now that stopped servers may be included (since JupyterHub 3.0),
this is the simplest way to select stopped servers.
Always equivalent to `not (ready or pending)`.
pending: pending:
type: string type: string
description: | description: |

View File

@@ -22,15 +22,16 @@ const store = createStore(reducers, initialState);
const App = () => { const App = () => {
useEffect(() => { useEffect(() => {
let { limit, user_page, groups_page } = initialState; let { limit, user_page, groups_page } = initialState;
jhapiRequest(`/users?offset=${user_page * limit}&limit=${limit}`, "GET") let api = withAPI()().props;
.then((data) => data.json()) api
.updateUsers(user_page * limit, limit)
.then((data) => .then((data) =>
store.dispatch({ type: "USER_PAGE", value: { data: data, page: 0 } }) store.dispatch({ type: "USER_PAGE", value: { data: data, page: 0 } })
) )
.catch((err) => console.log(err)); .catch((err) => console.log(err));
jhapiRequest(`/groups?offset=${groups_page * limit}&limit=${limit}`, "GET") api
.then((data) => data.json()) .updateGroups(groups_page * limit, limit)
.then((data) => .then((data) =>
store.dispatch({ type: "GROUPS_PAGE", value: { data: data, page: 0 } }) store.dispatch({ type: "GROUPS_PAGE", value: { data: data, page: 0 } })
) )

View File

@@ -4,7 +4,9 @@ import { jhapiRequest } from "./jhapiUtil";
const withAPI = withProps(() => ({ const withAPI = withProps(() => ({
updateUsers: (offset, limit, name_filter) => updateUsers: (offset, limit, name_filter) =>
jhapiRequest( jhapiRequest(
`/users?offset=${offset}&limit=${limit}&name_filter=${name_filter || ""}`, `/users?include_stopped_servers&offset=${offset}&limit=${limit}&name_filter=${
name_filter || ""
}`,
"GET" "GET"
).then((data) => data.json()), ).then((data) => data.json()),
updateGroups: (offset, limit) => updateGroups: (offset, limit) =>

View File

@@ -187,22 +187,44 @@ class APIHandler(BaseHandler):
json.dumps({'status': status_code, 'message': message or status_message}) json.dumps({'status': status_code, 'message': message or status_message})
) )
def server_model(self, spawner): def server_model(self, spawner, *, user=None):
"""Get the JSON model for a Spawner """Get the JSON model for a Spawner
Assume server permission already granted""" Assume server permission already granted
"""
if isinstance(spawner, orm.Spawner):
# if an orm.Spawner is passed,
# create a model for a stopped Spawner
# not all info is available without the higher-level Spawner wrapper
orm_spawner = spawner
pending = None
ready = False
stopped = True
user = user
if user is None:
raise RuntimeError("Must specify User with orm.Spawner")
state = orm_spawner.state
else:
orm_spawner = spawner.orm_spawner
pending = spawner.pending
ready = spawner.ready
user = spawner.user
stopped = not spawner.active
state = spawner.get_state()
model = { model = {
'name': spawner.name, 'name': orm_spawner.name,
'last_activity': isoformat(spawner.orm_spawner.last_activity), 'last_activity': isoformat(orm_spawner.last_activity),
'started': isoformat(spawner.orm_spawner.started), 'started': isoformat(orm_spawner.started),
'pending': spawner.pending, 'pending': pending,
'ready': spawner.ready, 'ready': ready,
'url': url_path_join(spawner.user.url, url_escape_path(spawner.name), '/'), 'stopped': stopped,
'url': url_path_join(user.url, url_escape_path(spawner.name), '/'),
'user_options': spawner.user_options, 'user_options': spawner.user_options,
'progress_url': spawner._progress_url, 'progress_url': user.progress_url(spawner.name),
} }
scope_filter = self.get_scope_filter('admin:server_state') scope_filter = self.get_scope_filter('admin:server_state')
if scope_filter(spawner, kind='server'): if scope_filter(spawner, kind='server'):
model['state'] = spawner.get_state() model['state'] = state
return model return model
def token_model(self, token): def token_model(self, token):
@@ -248,10 +270,22 @@ class APIHandler(BaseHandler):
keys.update(allowed_keys) keys.update(allowed_keys)
return model return model
_include_stopped_servers = None
@property
def include_stopped_servers(self):
"""Whether stopped servers should be included in user models"""
if self._include_stopped_servers is None:
self._include_stopped_servers = self.get_argument(
"include_stopped_servers", "0"
).lower() not in {"0", "false"}
return self._include_stopped_servers
def user_model(self, user): def user_model(self, user):
"""Get the JSON model for a User object""" """Get the JSON model for a User object"""
if isinstance(user, orm.User): if isinstance(user, orm.User):
user = self.users[user.id] user = self.users[user.id]
include_stopped_servers = self.include_stopped_servers
model = { model = {
'kind': 'user', 'kind': 'user',
'name': user.name, 'name': user.name,
@@ -291,18 +325,29 @@ class APIHandler(BaseHandler):
if '' in user.spawners and 'pending' in allowed_keys: if '' in user.spawners and 'pending' in allowed_keys:
model['pending'] = user.spawners[''].pending model['pending'] = user.spawners[''].pending
servers = model['servers'] = {} servers = {}
scope_filter = self.get_scope_filter('read:servers') scope_filter = self.get_scope_filter('read:servers')
for name, spawner in user.spawners.items(): for name, spawner in user.spawners.items():
# include 'active' servers, not just ready # include 'active' servers, not just ready
# (this includes pending events) # (this includes pending events)
if spawner.active and scope_filter(spawner, kind='server'): if (spawner.active or include_stopped_servers) and scope_filter(
spawner, kind='server'
):
servers[name] = self.server_model(spawner) servers[name] = self.server_model(spawner)
if not servers and 'servers' not in allowed_keys:
if include_stopped_servers:
# add any stopped servers in the db
seen = set(servers.keys())
for name, orm_spawner in user.orm_spawners.items():
if name not in seen and scope_filter(orm_spawner, kind='server'):
servers[name] = self.server_model(orm_spawner, user=user)
if "servers" in allowed_keys or servers:
# omit servers if no access # omit servers if no access
# leave present and empty # leave present and empty
# if request has access to read servers in general # if request has access to read servers in general
model.pop('servers') model["servers"] = servers
return model return model
def group_model(self, group): def group_model(self, group):

View File

@@ -1,6 +1,7 @@
"""Tests for named servers""" """Tests for named servers"""
import asyncio import asyncio
import json import json
import time
from unittest import mock from unittest import mock
from urllib.parse import unquote, urlencode, urlparse from urllib.parse import unquote, urlencode, urlparse
@@ -61,6 +62,7 @@ async def test_default_server(app, named_servers):
'url': user.url, 'url': user.url,
'pending': None, 'pending': None,
'ready': True, 'ready': True,
'stopped': False,
'progress_url': 'PREFIX/hub/api/users/{}/server/progress'.format( 'progress_url': 'PREFIX/hub/api/users/{}/server/progress'.format(
username username
), ),
@@ -148,6 +150,7 @@ async def test_create_named_server(
'url': url_path_join(user.url, escapedname, '/'), 'url': url_path_join(user.url, escapedname, '/'),
'pending': None, 'pending': None,
'ready': True, 'ready': True,
'stopped': False,
'progress_url': 'PREFIX/hub/api/users/{}/servers/{}/progress'.format( 'progress_url': 'PREFIX/hub/api/users/{}/servers/{}/progress'.format(
username, escapedname username, escapedname
), ),
@@ -433,3 +436,61 @@ async def test_named_server_stop_server(app, username, named_servers):
assert user.spawners[server_name].server is None assert user.spawners[server_name].server is None
assert user.spawners[''].server assert user.spawners[''].server
assert user.running assert user.running
@pytest.mark.parametrize(
"include_stopped_servers",
[True, False],
)
async def test_stopped_servers(app, user, named_servers, include_stopped_servers):
r = await api_request(app, 'users', user.name, 'server', method='post')
r.raise_for_status()
r = await api_request(app, 'users', user.name, 'servers', "named", method='post')
r.raise_for_status()
# wait for starts
for i in range(60):
r = await api_request(app, 'users', user.name)
r.raise_for_status()
user_model = r.json()
if not all(s["ready"] for s in user_model["servers"].values()):
time.sleep(1)
else:
break
else:
raise TimeoutError(f"User never stopped: {user_model}")
r = await api_request(app, 'users', user.name, 'server', method='delete')
r.raise_for_status()
r = await api_request(app, 'users', user.name, 'servers', "named", method='delete')
r.raise_for_status()
# wait for stops
for i in range(60):
r = await api_request(app, 'users', user.name)
r.raise_for_status()
user_model = r.json()
if not all(s["stopped"] for s in user_model["servers"].values()):
time.sleep(1)
else:
break
else:
raise TimeoutError(f"User never stopped: {user_model}")
# we have two stopped servers
path = f"users/{user.name}"
if include_stopped_servers:
path = f"{path}?include_stopped_servers"
r = await api_request(app, path)
r.raise_for_status()
user_model = r.json()
servers = list(user_model["servers"].values())
if include_stopped_servers:
assert len(servers) == 2
assert all(s["last_activity"] for s in servers)
assert all(s["started"] is None for s in servers)
assert all(s["stopped"] for s in servers)
assert not any(s["ready"] for s in servers)
assert not any(s["pending"] for s in servers)
else:
assert user_model["servers"] == {}