mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-07 01:54:09 +00:00
Add detailed doc for starting/stopping/waiting for servers via api
and complete implementation in examples/server-api
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
@@ -22,6 +22,9 @@ extensions = [
|
||||
'myst_parser',
|
||||
]
|
||||
|
||||
myst_enable_extensions = [
|
||||
'deflist',
|
||||
]
|
||||
# The master toctree document.
|
||||
master_doc = 'index'
|
||||
|
||||
|
@@ -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
|
||||
|
367
docs/source/reference/server-api.md
Normal file
367
docs/source/reference/server-api.md
Normal 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
|
||||
```
|
55
examples/server-api/jupyterhub_config.py
Normal file
55
examples/server-api/jupyterhub_config.py
Normal 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'
|
173
examples/server-api/start-stop-server.py
Normal file
173
examples/server-api/start-stop-server.py
Normal 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()
|
Reference in New Issue
Block a user