diff --git a/.gitignore b/.gitignore index 338f2f00..8a226ee2 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/docs/source/conf.py b/docs/source/conf.py index 3da73605..20ab77a4 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -22,6 +22,9 @@ extensions = [ 'myst_parser', ] +myst_enable_extensions = [ + 'deflist', +] # The master toctree document. master_doc = 'index' diff --git a/docs/source/reference/index.rst b/docs/source/reference/index.rst index 76177072..b985cbf6 100644 --- a/docs/source/reference/index.rst +++ b/docs/source/reference/index.rst @@ -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 diff --git a/docs/source/reference/server-api.md b/docs/source/reference/server-api.md new file mode 100644 index 00000000..ea83ce5c --- /dev/null +++ b/docs/source/reference/server-api.md @@ -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 ` +2. the {ref}`progress API ` + +(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 /user/test-1/", + "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 /user/test-user/", "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 +``` diff --git a/examples/server-api/jupyterhub_config.py b/examples/server-api/jupyterhub_config.py new file mode 100644 index 00000000..110b6d10 --- /dev/null +++ b/examples/server-api/jupyterhub_config.py @@ -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' diff --git a/examples/server-api/start-stop-server.py b/examples/server-api/start-stop-server.py new file mode 100644 index 00000000..5eeb55b8 --- /dev/null +++ b/examples/server-api/start-stop-server.py @@ -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()