mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-08 02:24:08 +00:00
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:
@@ -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: |
|
||||
|
@@ -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 } })
|
||||
)
|
||||
|
@@ -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) =>
|
||||
|
@@ -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):
|
||||
|
@@ -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"] == {}
|
||||
|
Reference in New Issue
Block a user