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