HTTP API
This page lists every HTTP route shippyd serves, who may call it and what it answers. You need it to write a monitoring check, debug a client against a box, or read the box's logs. You never call these routes to use shippy: the shippy client does it for you.
Bodies are JSON unless noted. Digests and keys are 64 hex characters. Terms used below:
- Pre-key: a hash of what a compilation reads that is known before rustc runs (toolchain, arguments, environment, compiled inputs).
- Manifest: the source files and
env!variables a compilation read, with their content. - Action key: the pre-key and a manifest together. A result is stored under it.
- Blob: a file stored under its own BLAKE3 digest.
- Claim: a result an untrusted principal uploaded, seen only by that principal until the box re-runs it.
- Pack: the list of results a whole build used, stored per build id (a name for "a build like this one").
The glossary has more.
A miss, then a hit
A wrapper that misses locally asks twice: lookup for the manifests under its pre-key, each with its result, then blobs/fetch for the outputs it lacks. Machine A's upload is a claim if A's token is untrusted: Machine C only gets it once the box has re-run it, or immediately if A is trusted. See the wire and trust.
Authentication
Every /v1/ route needs Authorization: Bearer <token>. A missing or unknown token gets 401 with WWW-Authenticate: Bearer.
| Token role | Routes it reaches |
|---|---|
trusted, untrusted | the client routes below, and /metrics |
metrics | /metrics only |
worker, trusted-worker | the /v1/worker/* routes only |
/healthz and /readyz need no token. See token roles.
Client routes
For trusted and untrusted tokens.
| Method and path | Body | Answer |
|---|---|---|
GET /v1/lookup/{pre_key} | none | The manifests under the pre-key, newest first, each with the result the caller may see. |
POST /v1/blobs/fetch | list of blob asks | One stream of frames, zstd if asked. |
GET /v1/pack/{build_id} | none | The build's pack: what the caller and others listed. 304 or 404. |
PUT /v1/pack/{build_id} | list of pack entries | 204. 413 past 20,000 entries. |
GET /v1/manifests/{pre_key} | none | The manifests under the pre-key, newest first. |
GET /v1/action/{key} | none | The result for the action key, or 404. |
PUT /v1/action/{key} | recipe and result | stored_as (the trust it was stored with) and queued_for_verification. |
POST /v1/blobs/missing | list of blob asks | The digests the caller must send. |
POST /v1/blobs/put | frames, raw or zstd | stored and refused digests, each once. 400 past 1,024 refused blobs. |
GET /v1/blob/{digest} | none | The blob's raw bytes. ?key=<action key> names a result that lists it. |
PUT /v1/blob/{digest} | raw bytes | 204. 400 when the bytes are not that digest. |
POST /v1/exec | recipe and inputs | Compiles a miss in the sandbox. See below. |
GET /v1/stats | none | The box's counters. See below. |
Who sees what
lookup,packandactionanswer with the public result of a key, else the caller's own claim, and nothing else. Entries with no such result are left out.- A blob is served to a principal that sent those bytes itself, or that asks through a result it may see (an output or the recipe of that result). A digest alone is not enough.
- A blob the caller may not read is answered like one the box lacks: no frame, 404, or "missing". So no route says whether someone else's blob is there.
Blob asks
A blob ask in JSON is either a bare digest (a blob the caller sent itself) or an object {"key": <action key>, "blobs": [<digests>]} (blobs that result names). One request may mix both.
Frames
blobs/fetch answers, and blobs/put sends, one stream of frames. Each frame is the blob's 32-byte digest, its length as 8 bytes little-endian, then its bytes. Blobs the box lacks have no frame.
- Send
Accept-Encoding: zstdto get the same frames as one zstd stream (Content-Encoding: zstd). blobs/puttakesContent-Encoding: zstdtoo. Any other encoding gets 415.- The box keeps a frame only when its bytes have the digest it names. A bad frame costs only itself; the rest of the request goes on.
Packs
GET /v1/pack/{build_id} answers with an ETag. Send it back as If-None-Match and an unchanged pack is answered 304 with no body, so a rebuild after an edit costs one small request. Every pack answer also carries the X-Shippy-Exec header (below).
Remote execution: POST /v1/exec
The client sends Accept: application/x-shippy-exec and a body of frames: the first holds the request as JSON, the others are inputs the client expects the box to lack. Before anything runs, the box answers with a status:
| Status | Meaning | What the client does |
|---|---|---|
| 200 | The request has a place in the queue. The body is a stream of records until the run ends. | Reads the result and output blobs from the stream. |
| 424 | Declared inputs are not in the store (or not readable by this caller). The body lists them in the recipe's order, without saying which is which. | Sends them and asks again, at most twice. |
| 429 | The caller has as many requests running and waiting as it may, or the queue is full. Has Retry-After. | Compiles here, and asks the box to compile nothing for Retry-After seconds. |
| 413 | The request is larger than the box takes. | Asks once more without inputs, else compiles here. |
| 400 | The request is not one the box can read. | Asks once more uncompressed, else compiles here. |
| 507 | The inputs would take this principal past its share of the store. | Compiles here. |
| 404 or 415 | A box from before this route. | Compiles here for an hour. |
The stream's records are a heartbeat (every five seconds), events as JSON, and the pieces of each output blob. Without the Accept header the route answers the result as JSON, or 422 with the reason. A declared input that is not in the store, or not readable by this caller, is such a reason ("missing input"), given before the request waits for a slot.
Every answer carries X-Shippy-Exec, a comma-separated list of what the box can do beyond the first version of the route: zstd (the request body may be one zstd stream) and finds (a request that declares a whole package may be answered from a result the box already has). See remote execution.
Worker routes
For worker and trusted-worker tokens only. Any other token gets 401. A worker reads no blob by digest, looks up no key and uploads nothing through the client routes.
| Method and path | What it does |
|---|---|
POST /v1/worker/poll | Asks for a lease: a sandbox run to do. |
POST /v1/worker/inputs/{lease} | Fetches the inputs of a lease it holds. |
POST /v1/worker/heartbeat/{lease} | Says the lease is alive. |
POST /v1/worker/early/{lease} | Sends the crate's metadata ahead of the result. |
POST /v1/worker/complete/{lease} | Hands back the result. |
A lease the worker lost or that ended gets 410. A completion the box refuses (outputs the recipe does not allow, bytes that are not their digest) gets 422 and the run goes to another worker. See workers.
Probes and metrics
| Method and path | Token | Answer |
|---|---|---|
GET /healthz | none | ok: the process answers. |
GET /readyz | none | ready, or 503 with not ready: <reasons>. |
GET /metrics | any role but a worker's | Prometheus text format. |
/readyz is ready when the store's directories are there, the tokens file can be used, the last sandbox self-test passed and the box is not shutting down. The self-test compiles a crate at start and every five minutes (lifecycle.selftest_every). A box with exec.local = false has no sandbox to test and counts as passed.
metrics.listen (such as 127.0.0.1:9447) serves /metrics, /healthz and /readyz on a second address without a token. Keep it on loopback.
curl -fsS http://box:7447/healthz
curl -fsS http://box:7447/readyz
curl -fsS -H "Authorization: Bearer $TOKEN" http://box:7447/v1/stats | jq .GET /v1/stats
One JSON object.
| Field | What it counts |
|---|---|
blobs, blob_bytes | Blobs in the store and their size. |
actions_trusted, actions_verified, actions_claimed | Results by trust: uploaded by a trusted principal, produced by the box's sandbox, and claims. |
verify_queue | Claims waiting for a re-run. |
verified_match | Claims whose bytes equalled the sandbox's. |
verified_equivalent | Claims whose rlibs differed only in archive member names. |
verified_mismatch | Claims whose content differed from the sandbox's. |
verified_incremental | Claims that differed and whose recipe names incremental state (not in verified_mismatch). |
verify_failed | Re-runs that failed. |
trusted_divergence | Trusted uploads whose content differed from the result already published for the key. |
claims_not_verifiable | Claims of a kind the box never re-runs. |
lookups_hit, lookups_miss | Lookups that found a result, and ones that did not. |
evicted_results, evicted_claims, evicted_packs, evicted_blobs, evicted_bytes | What eviction removed since the box started. |
store_bytes, store_limit | The store's size and limit at the last eviction round. |
sandbox | How runs are confined: limits (cgroup or rlimit), seccomp, and once known image and host_tools. |
exec | Remote execution counters, below. |
workers | The worker fleet, present only once leasing matters. |
Under exec:
| Field | What it counts |
|---|---|
exec_slots, exec_running, exec_waiting | Slots, and requests running and waiting now. |
exec_started | Requests that got a slot. |
exec_answered | Requests answered from a result that was already public. |
exec_refused | Requests refused with 429. |
exec_too_large | Requests refused for their size (413). |
exec_missing_inputs | Requests answered 424. |
exec_early | Runs whose metadata was sent ahead of the result. |
exec_compile_errors, exec_failed | Runs where the compiler failed, and runs that failed for another reason. |
exec_discovered, exec_narrowed, exec_widened, exec_found | For requests that declared a whole package: passes that found what the compiler reads, requests compiled with exactly that, ones compiled again with everything, and ones answered without a run. |
Status codes worth knowing
| Status | Where | Meaning |
|---|---|---|
| 304 | GET /v1/pack | The pack has not changed since the ETag you sent. |
| 400 | any route | A malformed digest, body or request. One plain-text line says what. |
| 401 | any authenticated route | No token, or one that is unknown, expired, revoked or of the wrong role. |
| 404 | action, blob, pack | No such result, blob or pack, or one the caller may not read. An old box also answers 404 for routes it lacks. |
| 410 | worker routes | The lease is not this worker's any more. |
| 413 | uploads, pack, exec | The body is larger than the route takes. |
| 415 | blobs/put | A Content-Encoding other than none or zstd. |
| 422 | exec (plain JSON), worker complete | The box would not accept the request or the result. |
| 424 | exec | Declared inputs are missing; the body lists them. |
| 429 | exec, blob, blobs/fetch, uploads | The box is busy for this caller: no room to wait, or as many answers open as streams as one principal may have. Wait for Retry-After seconds. |
| 503 | blobs/fetch, blobs/put, /readyz | 256 blob streams already run, or 64 of one untrusted principal's, or the box is not ready. |
| 507 | uploads, pack, exec | An untrusted principal would pass its share of the store (store.principal_share). The next eviction round makes room out of its own oldest data. |
Errors are a status and one line of plain text. Every answer carries x-request-id; send your own (8 to 64 letters, digits, - or _) to match a client's log line with the box's.
Go deeper
- crates/shippy-core/src/proto.rs: wire types and the route table
- DESIGN.md: "The shared box" and "The wire"
- docs/remote-exec.md:
/v1/execin full - docs/workers.md: the worker routes
- docs/operations.md: metrics, probes and logs