Add detailed doc for starting/stopping/waiting for servers via api

and complete implementation in examples/server-api
This commit is contained in:
Min RK
2021-08-03 21:55:45 +02:00
parent 71e9767307
commit b1b6a9e76c
6 changed files with 601 additions and 0 deletions

2
.gitignore vendored
View File

@@ -8,6 +8,7 @@ dist
docs/_build
docs/build
docs/source/_static/rest-api
docs/source/rbac/scope-table.md
.ipynb_checkpoints
# ignore config file at the top-level of the repo
# but not sub-dirs
@@ -30,3 +31,4 @@ pip-wheel-metadata
docs/source/reference/metrics.rst
oldest-requirements.txt
jupyterhub-proxy.pid
examples/server-api/service-token

View File

@@ -22,6 +22,9 @@ extensions = [
'myst_parser',
]
myst_enable_extensions = [
'deflist',
]
# The master toctree document.
master_doc = 'index'

View File

@@ -16,6 +16,7 @@ what happens under-the-hood when you deploy and configure your JupyterHub.
proxy
separate-proxy
rest
server-api
monitoring
database
templates

View File

@@ -0,0 +1,367 @@
# Starting servers with the JupyterHub API
JupyterHub's [REST API][] allows launching.
This allows you to build services launching Jupyter-based services for users
without relying on the JupyterHub API at all.
[BinderHub][] is an example of such an application.
[binderhub]: https://binderhub.readthedocs.io
[rest api]: ../reference/rest.md
This document provides an example of working with the JupyterHub API to
manager servers for users.
In particular, we will cover how to:
1. [check status of servers](checking)
2. [start servers](starting)
3. [wait for servers to be ready](waiting)
4. [communicate with servers](communicating)
5. [stop servers](stopping)
(checking)=
## Checking server status
Requesting information about a user includes a `servers` field,
which is a dictionary.
```
GET /hub/api/users/:username
```
**Required scope: `read:servers`**
```python
{
"admin": false,
"groups": [],
"pending": null,
"server": null,
"name": "test-1",
"kind": "user",
"last_activity": "2021-08-03T18:12:46.026411Z",
"created": "2021-08-03T18:09:59.767600Z",
"roles": ["user"],
"servers": {}
}
```
If the `servers` dict is empty, the user has no running servers.
The keys of the `servers` dict are server names as strings.
Many JupyterHub deployments only use the 'default' server,
which has the empty string `''` for a name.
In this case, the servers dict will always have either zero or one elements.
This is the servers dict when the user's default server is fully running and ready:
```python
"servers": {
"": {
"name": "",
"last_activity": "2021-08-03T18:48:35.934000Z",
"started": "2021-08-03T18:48:29.093885Z",
"pending": null,
"ready": true,
"url": "/user/test-1/",
"user_options": {},
"progress_url": "/hub/api/users/test-1/server/progress",
}
}
```
Key properties of a server:
name
: the server's name. Always the same as the key in `servers`
ready
: boolean. If True, the server can be expected to respond to requests at `url`.
pending
: `null` or a string indicating a transitional state (such as `start` or `stop`).
Will always be `null` if `ready` is true,
and will always be a string if `ready` is false.
url
: The server's url (just the path, e.g. `/users/:name/:servername/`)
where the server can be accessed if `ready` is true.
progress_url
: The API url path (starting with `/hub/api`)
where the progress API can be used to wait for the server to be ready.
See below for more details on the progress API.
last_activity
: ISO8601 timestamp indicating when activity was last observed on the server
started
: ISO801 timestamp indicating when the server was last started
We've seen the `servers` model with no servers and with one 'ready' server.
Here is what it looks like immediately after requesting a server launch,
while the server is not ready yet:
```python
...
"servers": {
"": {
"name": "",
"last_activity": "2021-08-03T18:48:29.093885Z",
"started": "2021-08-03T18:48:29.093885Z",
"pending": "spawn",
"ready": false,
"url": "/user/test-1/",
"user_options": {},
"progress_url": "/hub/api/users/test-1/server/progress",
}
}
```
Note that `ready` is false and `pending` is `spawn`.
This means that the server is not ready
(attempting to access it may not work)
because it isn't finished spawning yet.
We'll get more into that below in [waiting for a server][].
[waiting for a server]: waiting
(starting)=
## Starting servers
To start a server, make the request
```
POST /hub/api/users/:username/servers/[:servername]
```
**Required scope: `servers`**
(omit servername for the default server)
Assuming the request was valid,
there are two possible responses:
201 Created
: This status code means the launch completed and the server is ready.
It should be available at the server's URL immediately.
202 Accepted
: This is the more likely response,
and means that the server has begun launching,
but wasn't immediately ready.
The server has `pending: 'spawn'` at this point.
_Aside: how quickly JupyterHub responds with `202 Accepted` is governed by the `slow_spawn_timeout` tornado setting._
(waiting)=
## Waiting for a server
If you are starting a server via the API,
there's a good change you want to know when it's ready.
There are two ways to do with:
1. {ref}`Polling the server model <polling>`
2. the {ref}`progress API <progress>`
(polling)=
### Polling the server model
The simplest way to check if a server is ready
is to request the user model.
If:
1. the server name is in the user's `servers` model, and
2. `servers['servername']['ready']` is true
A Python example, checking if a server is ready:
```python
def server_ready(hub_url, user, server_name="", token):
r = requests.get(
f"{hub_url}/hub/api/users/{user}/servers/{server_name}",
headers={"Authorization": f"token {token}"},
)
r.raise_for_status()
user_model = r.json()
servers = user_model.get("servers", {})
if server_name not in servers:
return False
server = servers[server_name]
if server['ready']:
print(f"Server {user}/{server_name} ready at {server['url']}")
return True
else:
print(f"Server {user}/{server_name} not ready, pending {server['pending']}")
return False
```
You can keep making this check until `ready` is true.
(progress)=
### Progress API
The most _efficient_ way to wait for a server to start is the progress API.
The progress URL is available in the server model under `progress_url`,
and has the form `/hub/api/users/:user/servers/:servername/progress`.
_the default server progress can be accessed at `:user/servers//progress` or `:user/server/progress`_
```
GET /hub/api/users/:user/servers/:servername/progress
```
**Required scope: `read:servers`**
This is an [EventStream][] API.
In an event stream, messages are _streamed_ and delivered on lines of the form:
```
data: {"progress": 10, "message": "...", ...}
```
where the line after `data:` contains a JSON-serialized dictionary.
Lines that do not start with `data:` should be ignored.
[eventstream]: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#examples
progress events have the form:
```python
{
"progress": 0-100,
"message": ""
"ready": true, # or false
}
```
progress
: integer, 0-100
message
: string message describing progress stages
ready
: present and true only for the last event when the server is ready
url
: only present if `ready` is true; will be the server's url
the progress API can be used even with fully ready servers.
If the server is ready,
there will only be one event that looks like:
```json
{
"progress": 100,
"ready": true,
"message": "Server ready at /user/test-1/",
"html_message": "Server ready at <a href=\"/user/test-1/\">/user/test-1/</a>",
"url": "/user/test-1/"
}
```
where `ready` and `url` are the same as in the server model (`ready` will always be true).
A typical complete stream from the event-stream API:
```
data: {"progress": 0, "message": "Server requested"}
data: {"progress": 50, "message": "Spawning server..."}
data: {"progress": 100, "ready": true, "message": "Server ready at /user/test-user/", "html_message": "Server ready at <a href=\"/user/test-user/\">/user/test-user/</a>", "url": "/user/test-user/"}
```
Here is a Python example for consuming an event stream:
```{literalinclude} ../../../examples/server-api/start-stop-server.py
:language: python
:pyobject: event_stream
```
(stopping)=
## Stopping servers
Servers can be stopped with a DELETE request:
```
DELETE /hub/api/users/:user/servers/[:servername]
```
**Required scope: `servers`**
Like start, delete may not complete immediately.
The DELETE request has two possible response codes:
204 Deleted
: This status code means the delete completed and the server is fully stopped.
It will now be absent from the user `servers` model.
202 Accepted
: Like start, `202` means your request was accepted,
but is not yet complete.
The server has `pending: 'stop'` at this point.
Unlike start, there is no progress API for stop.
To wait for stop to finish, you must poll the user model
and wait for the server to disappear from the user `servers` model.
```{literalinclude} ../../../examples/server-api/start-stop-server.py
:language: python
:pyobject: stop_server
```
(communicating)=
## Communicating with servers
JupyterHub tokens with the the `access:servers` scope
can be used to communicate with servers themselves.
This can be the same token you used to launch your service.
```{note}
Access scopes are new in JupyterHub 2.0.
To access servers in JupyterHub 1.x,
a token must be owned by the same user as the server,
*or* be an admin token if admin_access is enabled.
```
The URL returned from a server model is the url path suffix,
e.g. `/user/:name/` to append to the jupyterhub base URL.
For instance, `{hub_url}{server_url}`,
where `hub_url` would be e.g. `http://127.0.0.1:8000` by default,
and `server_url` `/user/myname`,
for a full url of `http://127.0.0.1:8000/user/myname`.
## Python example
The JupyterHub repo includes a complete example in {file}`examples/server-api`
tying all this together.
To summarize the steps:
1. the `/user/:name`
2. the server model includes a `ready` state to tell you if it's ready
3. if it's not ready, you can use the `progress_url` field to wait
4. if it is ready, you can use the `url` field to link directly to the running srver
The example demonstrates starting and stopping servers via the JupyterHub API,
including waiting for them to start via the progress API,
as well as waiting for them to stop via polling the user model.
```{literalinclude} ../../../examples/server-api/start-stop-server.py
:language: python
:start-at: def event_stream
:end-before: def main
```

View File

@@ -0,0 +1,55 @@
# create a role with permissions to:
# 1. start/stop servers, and
# 2. access the server API
c.JupyterHub.load_roles = [
{
"name": "launcher",
"scopes": [
"servers", # manage servers
"access:servers", # access servers themselves
],
# assign role to our 'launcher' service
"services": ["launcher"],
}
]
# persist token to a file, to share it with the launch-server.py script
import pathlib
import secrets
here = pathlib.Path(__file__).parent
token_file = here.joinpath("service-token")
if token_file.exists():
with token_file.open("r") as f:
token = f.read()
else:
token = secrets.token_hex(16)
with token_file.open("w") as f:
f.write(token)
# define our service
c.JupyterHub.services = [
{
"name": "launcher",
"api_token": token,
}
]
# ensure spawn requests return immediately,
# rather than waiting up to 10 seconds for spawn to complete
# this ensures that we use the progress API
c.JupyterHub.tornado_settings = {"slow_spawn_timeout": 0}
# create our test-user
c.Authenticator.allowed_users = {
'test-user',
}
# testing boilerplate: fake auth/spawner, localhost. Don't use this for real!
c.JupyterHub.authenticator_class = 'dummy'
c.JupyterHub.spawner_class = 'simple'
c.JupyterHub.ip = '127.0.0.1'

View File

@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""
Example of starting/stopping a server via the JupyterHub API
1. get user status
2. start server
3. wait for server to be ready via progress api
4. make a request to the server itself
5. stop server via API
6. wait for server to finish stopping
"""
import json
import logging
import pathlib
import time
import requests
log = logging.getLogger(__name__)
def get_token():
"""boilerplate: get token from share file.
Make sure to start jupyterhub in this directory first
"""
here = pathlib.Path(__file__).parent
token_file = here.joinpath("service-token")
log.info(f"Loading token from {token_file}")
with token_file.open("r") as f:
token = f.read()
return token
def make_session(token):
"""Create a requests.Session with our service token in the Authorization header"""
session = requests.Session()
session.headers = {"Authorization": f"token {token}"}
return session
def event_stream(session, url):
"""Generator yielding events from a JSON event stream
For use with the server progress API
"""
r = session.get(url, stream=True)
r.raise_for_status()
for line in r.iter_lines():
line = line.decode('utf8', 'replace')
# event lines all start with `data:`
# all other lines should be ignored (they will be empty)
if line.startswith('data:'):
yield json.loads(line.split(':', 1)[1])
def start_server(session, hub_url, user, server_name=""):
"""Start a server for a jupyterhub user
Returns the full URL for accessing the server
"""
user_url = f"{hub_url}/hub/api/users/{user}"
log_name = f"{user}/{server_name}".rstrip("/")
# step 1: get user status
r = session.get(user_url)
r.raise_for_status()
user_model = r.json()
# if server is not 'active', request launch
if server_name not in user_model.get('servers', {}):
log.info(f"Starting server {log_name}")
r = session.post(f"{user_url}/servers/{server_name}")
r.raise_for_status()
if r.status_code == 201:
log.info(f"Server {log_name} is launched and ready")
elif r.status_code == 202:
log.info(f"Server {log_name} is launching...")
else:
log.warning(f"Unexpected status: {r.status_code}")
r = session.get(user_url)
r.raise_for_status()
user_model = r.json()
# report server status
server = user_model['servers'][server_name]
if server['pending']:
status = f"pending {server['pending']}"
elif server['ready']:
status = "ready"
else:
# shouldn't be possible!
raise ValueError(f"Unexpected server state: {server}")
log.info(f"Server {log_name} is {status}")
# wait for server to be ready using progress API
progress_url = user_model['servers'][server_name]['progress_url']
for event in event_stream(session, f"{hub_url}{progress_url}"):
log.info(f"Progress {event['progress']}%: {event['message']}")
if event.get("ready"):
server_url = event['url']
break
else:
# server never ready
raise ValueError(f"{log_name} never started!")
# at this point, we know the server is ready and waiting to receive requests
# return the full URL where the server can be accessed
return f"{hub_url}{server_url}"
def stop_server(session, hub_url, user, server_name=""):
"""Stop a server via the JupyterHub API
Returns when the server has finished stopping
"""
# step 1: get user status
user_url = f"{hub_url}/hub/api/users/{user}"
server_url = f"{user_url}/servers/{server_name}"
log_name = f"{user}/{server_name}".rstrip("/")
log.info(f"Stopping server {log_name}")
r = session.delete(server_url)
if r.status_code == 404:
log.info(f"Server {log_name} already stopped")
r.raise_for_status()
if r.status_code == 204:
log.info(f"Server {log_name} stopped")
return
# else: 202, stop requested, but not complete
# wait for stop to finish
log.info(f"Server {log_name} stopping...")
# wait for server to be done stopping
while True:
r = session.get(user_url)
r.raise_for_status()
user_model = r.json()
if server_name not in user_model.get("servers", {}):
log.info(f"Server {log_name} stopped")
return
server = user_model["servers"][server_name]
if not server['pending']:
raise ValueError(f"Waiting for {log_name}, but no longer pending.")
log.info(f"Server {log_name} pending: {server['pending']}")
# wait to poll again
time.sleep(1)
def main():
"""Start and stop one server
Uses test-user and hub from jupyterhub_config.py in this directory
"""
user = "test-user"
hub_url = "http://127.0.0.1:8000"
session = make_session(get_token())
server_url = start_server(session, hub_url, user)
r = session.get(f"{server_url}/api/status")
r.raise_for_status()
log.info(f"Server status: {r.text}")
stop_server(session, hub_url, user)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
main()