Skip to main content

WAI Extension: Deployment

Mirrored from the canonical text at commit c5e7133e ().

Status: Draft. A deployment is a project’s built tree: dist/ as wai build writes it, its files and its build record, wai-build.json, named by its digest, the SHA-256 of the build record’s bytes. A deployment target is a host that holds deployments by their digests and keeps one of them live. This extension states what a deployment is and what a target serves of it (§3), the API every target serves (§4), the receipt a target signs for each answer (§5), how a target holds its deployments (§6), the reference target (§7) and the toolchain’s operations (§8). It names no host: any host that serves §4 under an address is a target. The API’s OpenAPI document is deployment.openapi.json. The reference implementation is the module deployment of wai-rs (feature ed25519), the target wai_deploy_target and the operations of wai_cli/dx/deploy.rs; wai-rs/tests/cli_deploy.rs holds the three, and the OpenAPI document, to each other. Keywords MUST, MUST NOT, SHOULD, MAY are RFC 2119/8174.

Texts read for this revision: the WAI texts this one cites; RFC 8032; RFC 8259; RFC 8785; RFC 9110; FIPS 180-4; OpenAPI 3.1.0.

1. Scope

In scope: the tree a deployment is, its digest, the rules it is held to and what a target serves of it (§3); the routes of the API, their requests, answers and refusals (§4); the receipt, its members, its signature and what a client holds it to (§5); how a target holds files, deployments and its live one (§6); the reference target (§7); and the toolchain’s operations (§8).

Out of scope: who may change a target, which is decided in front of it (a request carries a key as every request of the toolchain does, wai auth login); names, certificates and caches in front of a target; a build on the target, which holds what was built and builds nothing; and removing a deployment, which this revision does not define.

2. Terms

3. A deployment

3.1 The tree

A deployment is the build record and every file it lists, each at its path, nothing else. A tree with a file the record does not list is not the record’s tree, and a client MUST NOT send it (§8.1); a name of a file or directory that starts with a dot is not part of a tree, as wai build writes none and wai verify reads none.

3.2 The digest

The digest is the SHA-256 of the build record’s bytes as wai build wrote them: RFC 8785 over one object, on one line, with no line ending. The record names every file by its SHA-256, so the digest names every byte of the tree.

3.3 The rules a tree is held to

A client before it sends a tree, and a target before it holds one, MUST refuse a build record that is not

  1. the RFC 8785 form of what it says, byte for byte;
  2. an object with exactly the members $schema (https://wai.transaction.science/schema/wai-build.json/1), wai (1), project (sha256, and name where it has one), lock (sha256 alone), toolchain (an object) and files;
  3. a files list of objects of exactly path, sha256 (64 lowercase hexadecimal digits) and bytes (a whole number of at most 2^53 − 1), sorted by path as bytes, each path once;
  4. with every path 1 to 1024 bytes of /-joined segments, none empty, . or .., none holding a backslash or a control character, none wai-build.json, and no path a directory of another (a beside a/b).

A client MUST also hold each file of dist/ to the record: its SHA-256 and size those the record states. A target MUST hold each file it is sent to the SHA-256 it is put at (§4.2), and a file a record lists to the size the record states (§4.3).

3.4 What a target serves

A target that serves its live deployment serves each file of the tree at its path under the path its list names as serves (§4.1), byte for byte, and the build record at wai-build.json, so that anyone reading it can hold the tree to it. A request for that path itself, or for a path that ends with /, is the index.html there; a path that names no file of the tree, but whose <path>/index.html is one, is that file; any other path is 404. Each file’s media type is read from its name’s extension. A target MAY hold deployments and serve none, and then lists serves as null.

4. The API

Every path below is under the target’s address, which may itself have a path. A request and an answer with a body are JSON (RFC 8259), but a file’s bytes (§4.2). A request names a nonce, 32 to 128 lowercase hexadecimal digits, fresh for each request; the receipt repeats it (§5). A body is sent with Content-Length.

RequestOperationDoes
GET /v1/deployments?nonce=<n>deployment.listwhat the target holds, signed (§4.1)
PUT /v1/deployments/files/{sha256}deployment_file.createa file held by its SHA-256 (§4.2)
POST /v1/deploymentsdeployment.createa deployment held by its digest (§4.3)
POST /v1/deployments/promotedeployment.promotea deployment held made live (§4.4)
POST /v1/deployments/rollbackdeployment.rollbackthe one live before the live one made live again (§4.5)

4.1 List

GET /v1/deployments?nonce=<n> answers 200 with format (wai.deployments/1); live, the live deployment’s digest or null; history; held, every deployment held, sorted by digest, each with its digest, its project (sha256, and name where its record names one), how many files and how many bytes; serves (§3.4); limits.file_bytes, the largest file it takes; and receipt (§5), whose state is the digest of the held and history listed. It changes nothing.

4.2 A file

PUT /v1/deployments/files/{sha256} with a file’s bytes (application/octet-stream) holds them under {sha256}. Bytes that do not hash to it are refused, digest-mismatch (422), and nothing is kept. It answers 201 when the file is new and 200 when it was held, each with sha256, bytes and held: true. A file once held is never changed. A record is put the same way, under its digest.

4.3 Create

POST /v1/deployments with {"digest", "nonce"}. The target reads the file it holds under the digest as a build record (§3.3) and checks that it holds every file the record lists, by SHA-256 and size. When it does, it holds the deployment and answers 201 with created: true and the receipt. A deployment already held is the same deployment: 200, created: false, and nothing changes. When it lacks files, 409 files-missing with missing: each file it lacks, by path, sha256 and bytes, the record itself by the path wai-build.json and bytes: null. A record that does not hold to §3.3, or a file whose size is not the one the record states, is refused, tree-refused (422). A create never changes which deployment is live.

4.4 Promote

POST /v1/deployments/promote with {"digest", "nonce"}, and expect_live where the client read the live deployment first. A digest the target does not hold is refused, deployment-not-held (404), and nothing changes; an expect_live that is not the live deployment (null: none is live) is refused, live-changed (409). Otherwise the deployment is appended to the history and is live: 200, changed: true, and the receipt. Promoting the live deployment changes nothing: 200, changed: false.

4.5 Rollback

POST /v1/deployments/rollback with {"nonce"}, and expect_live as for a promotion. The last deployment of the history is removed from it, and the one before it is live: exactly that one, whatever was promoted since. A history of fewer than two deployments is refused, nothing-to-roll-back (409), and nothing changes. 200, changed: true, and the receipt, whose digest and live are the deployment made live and whose previous is the one rolled back.

4.6 Refusals

A refusal answers {"error": <code>, "message": <text>}, and the members its code names:

CodeStatusRaised by
invalid-request400a body, a nonce, a digest or a SHA-256 that is not one
not-found404a path under /v1/deployments/ that is no route
deployment-not-held404a promotion of a digest the target does not hold
method-not-allowed405a route asked with another method
files-missing409a create whose record or files are not all held; missing
live-changed409an expect_live that is not the live deployment; live
nothing-to-roll-back409a rollback with no deployment before the live one
length-required411a body without Content-Length
too-large413a body larger than the target takes; limit
digest-mismatch422a file whose bytes are not the SHA-256 it is put at; sha256, theirs
tree-refused422a record that does not hold to §3.3, or a size it states that a held file does not have
internal500a target that could not keep what it was sent; nothing changed

5. The receipt

Every answer to a list, a create, a promotion and a rollback carries receipt, an object of exactly these members:

MemberHolds
formatwai.deployment-receipt/1
opthe operation it answers: deployment.list, deployment.create, deployment.promote or deployment.rollback
keythe target’s Ed25519 public key (RFC 8032), 64 lowercase hexadecimal digits
noncethe request’s nonce
seqthe target’s count of changes, this one included
digestthe deployment the request named; for a rollback, the one made live; null for a list
livethe live deployment after the request, or null
previousthe live deployment before it, or null; live itself where the request did not change it
statethe SHA-256 of the RFC 8785 form of {"held": [...], "history": [...]} after the request, every digest held sorted, and the history oldest first
timethe target’s clock, YYYY-MM-DDTHH:MM:SSZ
signaturethe Ed25519 signature by key over the RFC 8785 form of every other member, 128 lowercase hexadecimal digits

A client MUST refuse an answer whose receipt does not verify, whose op is not the request’s, whose nonce is not the one it sent, or that is signed by another key than the target’s other answers in the same operation. It MUST also hold what the receipt states to the request: a list’s held and history to its state, and its live to the history’s last; a create’s digest to the deployment sent, and its live to its previous; a promotion’s digest and live to the deployment named, and its previous to the live deployment the client read; and a rollback’s live and digest to the deployment before the live one in the history the client read, and its previous to that live one. A refused receipt means the client does not know what the target did, and says so.

6. Holding

6.1 Files and deployments

A target keeps each file by its SHA-256 and never changes it, so a file several deployments list is held once. It holds a deployment from its create until this revision’s end: no request removes one.

6.2 The live deployment and its history

A promotion appends to the history, a rollback removes its last, and nothing else changes it. Each change is whole: a target keeps its state so that a crash leaves the state before the change or the state after it, and answers only after the state after it is kept. A request that is refused, or that changes nothing, leaves seq as it was.

6.3 Serving

A target that serves its live deployment serves it as §3.4 states, from the files it holds, so the bytes served are the bytes the record names.

7. The reference target

wai_deploy_target --dir <D> [--port P] [--bind ADDR] [--key <seed file>] [--max-file-bytes N] serves §4 over the directory D, which it owns: files/<sha256> each written whole and renamed into place, and state.json, the deployments held, the history and seq, written whole in place of the one before. Its key is the Ed25519 seed --key names (64 hexadecimal digits), else D/target.key, made on its first start, readable by its owner alone. On start it reads its state back and holds every deployment it names to its record and files again, by SHA-256, and does not start when one does not hold. It listens on 127.0.0.1 unless --bind names another address, on --port (0, the default, picks a free one), prints one JSON line when it is ready (ready, url, key, dir), answers one request at a time, serves its live deployment at / (serves: "/"), and takes files of at most --max-file-bytes (256 MiB). It has no accounts: who may reach it is decided in front of it.

8. The operations

The toolchain’s operations, each named by its rule (an operation noun.verb, its command line the words in order), each in a build with the feature quickstart:

OperationCommand lineRequestsEffect
deployment.createwai deploy [<dir>] [--target <name|url>] [--prebuilt] [--promote] [--key <name>]list; create; each file it lacks; create; with --promote, promotethe target
deployment.listwai deployment list [--target <name|url>]listreads
deployment.promotewai deployment promote <digest> [--target <name|url>] [--key <name>]list; promotethe target
deployment.rollbackwai deployment rollback [--target <name|url>] [--key <name>]list; rollbackthe target

8.1 wai deploy

wai deploy builds the project in <dir> (.) as wai build does, its checks and its receipt, and refuses as it refuses; with --prebuilt it takes dist/ as it is. Either way it holds the tree to its record (§3.3) before it sends anything: a file the record lists that is not there, or whose SHA-256 or size is another, or a file there the record does not list, is refused (exit 1, dist-changed), and nothing is sent. It then reads the target’s list and asks the target to hold the deployment by its digest; for each answer files-missing it puts what the target names, from dist/, each held to its SHA-256 again, and asks again (the record first, where the target lacks it, then the files the record lists). With --promote it then promotes the deployment, naming as expect_live the live one its create’s receipt states. A file the target already holds is not sent.

8.2 Every operation

--target names the target as every operation that acts on a service names one: a target’s name (wai target add) or the target’s address; each takes the target in use (wai target use) when none is named. A read-only target refuses each that changes it. A request carries the key wai auth login kept for the target’s origin, or WAI_TOKEN’s, and only over https:// or to this machine.

Each that changes the target waits for --yes (--confirm, deprecated, is the same) before it builds, writes or sends anything, and without it exits 4 with the proposed change and the command that confirms it. --dry-run sends nothing, not even a list, and writes nothing: it names each request it would send and each file it would write. --json writes the operation’s JSON envelope.

Each verifies every receipt it is answered with and holds it to its request (§5); a receipt that does not hold is refused (exit 1, receipt-refused). Each that changes the target writes each receipt of a change to .wai/deployments/<target>/<verb>-<nonce>.json under <dir> (for a promotion and a rollback, the current directory), <target> the first 16 hexadecimal digits of the SHA-256 of the target’s address and <nonce> the first 16 of the request’s, and then its own operation receipt (schema/README.md, Operation receipts), naming each as written and, for wai deploy, the build record as read; --key signs it. wai verify holds the receipts’ files to it.

A refusal by the target is the operation’s, by its code: deployment-not-held exits 5; live-changed, nothing-to-roll-back, digest-mismatch, tree-refused and files-missing exit 1; a target that cannot be reached exits 6.

9. Conformance

A target conforms when it serves §4 and signs §5 as stated, holds as §6 states, and refuses with the codes of §4.6. A client conforms when it holds a tree to §3.3 before sending it and every receipt to §5. wai-rs/tests/cli_deploy.rs holds the reference target and the toolchain’s operations to this text against each other, plants the faults this text refuses (a file of dist/ changed after its build, bytes put under another SHA-256, a promotion of a deployment the target does not hold, a rollback with none before the live one, a receipt answering another request) and requires each to be refused, requires a rollback to make live exactly the deployment live before, requires --dry-run to leave the target as it was, and holds the OpenAPI document’s routes, operations, codes and receipt members to the implementation’s.

10. Security considerations

A digest names every byte of a tree, so a target cannot serve, under a deployment’s digest, bytes the record does not name, and a client that holds its tree to its record sends no byte the build did not write. A receipt binds a target’s key to what it did for one request, by its nonce, so an answer to another request, or a receipt kept from before, is refused; it does not say who may hold that key, which is the target’s operator’s to publish. A target is reached as every service of the toolchain is (--target), and a key goes only over https:// or to this machine. The reference target listens on loopback and has no accounts: a target reached from elsewhere is put behind what decides who may reach it.