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.
schema:
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:
200:
description: The Hub's user list
@@ -1160,7 +1170,11 @@ components:
format: date-time
servers:
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:
$ref: "#/components/schemas/Server"
auth_state:
@@ -1182,6 +1196,15 @@ components:
description: |
Whether the server is ready for traffic.
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:
type: string
description: |

View File

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

View File

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

View File

@@ -187,22 +187,44 @@ class APIHandler(BaseHandler):
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
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 = {
'name': spawner.name,
'last_activity': isoformat(spawner.orm_spawner.last_activity),
'started': isoformat(spawner.orm_spawner.started),
'pending': spawner.pending,
'ready': spawner.ready,
'url': url_path_join(spawner.user.url, url_escape_path(spawner.name), '/'),
'name': orm_spawner.name,
'last_activity': isoformat(orm_spawner.last_activity),
'started': isoformat(orm_spawner.started),
'pending': pending,
'ready': ready,
'stopped': stopped,
'url': url_path_join(user.url, url_escape_path(spawner.name), '/'),
'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')
if scope_filter(spawner, kind='server'):
model['state'] = spawner.get_state()
model['state'] = state
return model
def token_model(self, token):
@@ -248,10 +270,22 @@ class APIHandler(BaseHandler):
keys.update(allowed_keys)
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):
"""Get the JSON model for a User object"""
if isinstance(user, orm.User):
user = self.users[user.id]
include_stopped_servers = self.include_stopped_servers
model = {
'kind': 'user',
'name': user.name,
@@ -291,18 +325,29 @@ class APIHandler(BaseHandler):
if '' in user.spawners and 'pending' in allowed_keys:
model['pending'] = user.spawners[''].pending
servers = model['servers'] = {}
servers = {}
scope_filter = self.get_scope_filter('read:servers')
for name, spawner in user.spawners.items():
# include 'active' servers, not just ready
# (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)
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
# leave present and empty
# if request has access to read servers in general
model.pop('servers')
model["servers"] = servers
return model
def group_model(self, group):

View File

@@ -1,6 +1,7 @@
"""Tests for named servers"""
import asyncio
import json
import time
from unittest import mock
from urllib.parse import unquote, urlencode, urlparse
@@ -61,6 +62,7 @@ async def test_default_server(app, named_servers):
'url': user.url,
'pending': None,
'ready': True,
'stopped': False,
'progress_url': 'PREFIX/hub/api/users/{}/server/progress'.format(
username
),
@@ -148,6 +150,7 @@ async def test_create_named_server(
'url': url_path_join(user.url, escapedname, '/'),
'pending': None,
'ready': True,
'stopped': False,
'progress_url': 'PREFIX/hub/api/users/{}/servers/{}/progress'.format(
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
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"] == {}