Skip to content
sitesolide

Docs Reference

View as Markdown

HTTP API

With a token, everything sitesolide deploy does is plain HTTPS to the dashboard. This page is for an agent, or a person, who holds a token but not the CLI: a ready-made script deploys any project, a static site, an app, services or workers, and each request it makes is described below.

VariableValue
SITESOLIDE_APIThe dashboard's address, https://dashboard.<zone>. There is no default; http:// is refused, but for localhost, 127.0.0.1 and [::1].
SITESOLIDE_TOKENA token, sst_ and 43 more characters, from the owner or minted on the dashboard's Tokens page: see Teammates and tokens.
export SITESOLIDE_API=https://dashboard.<zone>
read -rs SITESOLIDE_TOKEN && export SITESOLIDE_TOKEN

Warning

The token is a password: whoever holds it deploys what it allows. Keep it out of command arguments, logs, the repository's files and the conversation. An agent gets it in its environment from whoever runs it. A token that leaked is revoked on Tokens.

sitesolide.fr/deploy.sh does exactly what sitesolide deploy does with a token. It needs bash, curl, tar (GNU or bsdtar), gzip, find, mktemp and one JSON reader, the first found of jq, node, bun or python3: neither rsync nor the CLI. Download it outside the project's folder, since an app's archive would carry it, and read it before you run it:

curl -fsSL https://sitesolide.fr/deploy.sh -o ~/deploy.sh

GitHub releases carry the same script beside install.sh, from the first release that ships it: https://github.com/cthiriet/sitesolide/releases/latest/download/deploy.sh. With both variables exported, run it from the project's folder, the one holding sitesolide.json:

bash ~/deploy.sh
CommandDoes
bash deploy.sh [folder]Deploys the folder holding sitesolide.json, the current one by default.
bash deploy.sh --archive <file> [folder]Runs the build and writes the archive. Sends nothing, needs no token, runs the project's code all the same.
bash deploy.sh --helpPrints its usage.

A deployment exits 0 only once the machine says succeeded, and non-zero otherwise; --archive and --help exit 0 once done. The rest of this page is what the script does, request by request.

Every request carries Authorization: Bearer <token>. Hand the header to curl through a file descriptor: printf is a bash builtin, so the token is in no process's arguments and no history. -q, first, leaves ~/.curlrc unread, -g reads brackets in an address as brackets, and --proto =https refuses anything else. The examples below use this function:

api() { curl -q -sS -g --proto =https -H @<(printf 'Authorization: Bearer %s\n' "$SITESOLIDE_TOKEN") "$@"; }
api "$SITESOLIDE_API/api/v1/whoami"
{"identity":{"email":"alice@example.com","scope":{"slugs":["notes"],"create":true,"public":false,"outbound":false,"domain":false,"secrets":true},"owned":[],...}}

curl prints the body alone; the statuses below are given in the text, and -w '%{http_code}' prints them. Send the token to SITESOLIDE_API alone: build every address from it, never from one an answer names, and never follow a redirect.

  1. Check the folder. The CLI and the script refuse an app's code folder that holds node_modules, .venv, venv or __pycache__ at its top, unless exclude lists that exact name (node_modules, not /node_modules or node_modules/): the machine installs dependencies itself, with install. source and publicDir stay inside the git repository holding sitesolide.json, links resolved. Every name under secrets.build must be set, in your environment, or for the CLI in ~/.config/sitesolide/secrets/<slug>.env: one missing stops here, with nothing sent.
  2. Send the manifest. It is judged before anything is built.
  3. Run the build, then check that publicDir exists and is not empty.
  4. Build the archive, then upload it.
  5. Follow the log until the deployment ends.

The body is {"manifest": ...}, the text of sitesolide.json or its object. Every reason it is refused comes back at once, in details:

jq -Rs '{manifest: .}' sitesolide.json | api -H 'Content-Type: application/json' --data-binary @- "$SITESOLIDE_API/api/v1/deployments"

It answers 201, with the deployment's id:

{"deployment":{"id":"<id>","slug":"<slug>","state":"awaiting-bundle","creating":true,"log":[],"next":0,"error":null,"url":null,"allocated":[],...}}

The deployment waits 15 minutes for its archive. A token holds three waiting at most: a fourth replaces the oldest.

The manifest's build runs in the code folder, the manifest's source or its own folder, in a clean environment: from yours, only what locates your tools, PATH, HOME, the locale and the like; then the manifest's env, {zone} and {contact} replaced from the deployment's placeholders; then each name under secrets.build. Never a SITESOLIDE_ variable, the token among them, and never a value on a command line. It is the project's code and its dependencies', run as you all the same, with what you may read: run only a build you would run yourself. By hand, from the folder holding sitesolide.json, keep at least the token out of it:

build="$(jq -r '.build // empty' sitesolide.json)"
(cd <code folder> && env -u SITESOLIDE_TOKEN sh -c "$build")

A gzip-compressed tar whose only top-level entries are app/ and public/, those the project has:

EntrySent forHolds
app/An app: start or services.The code folder, minus exclude, .git, .env*, sitesolide.json and the publicDir, each read as an exclusion pattern.
public/A project with publicDir.The publicDir folder, minus .git and .env*.

Exclusion patterns follow rsync's rules:

  • a name without a slash matches at any depth: node_modules, .env*;
  • a pattern with a slash matches the end of the path, on a folder boundary: cache/tmp;
  • a leading slash anchors it at the root of the folder sent: /dist;
  • *, ? and [...] match within a name, ** across folders;
  • a trailing slash matches folders only: logs/.

Copy the trees into a stage outside the project's folder: inside it, the next deployment would carry the stage and the archive. For an app whose exclude is ["node_modules"] and whose publicDir is public, from its code folder, with one --exclude per pattern:

stage="$(mktemp -d)"
rsync -a --exclude node_modules --exclude .git --exclude '.env*' --exclude sitesolide.json --exclude public ./ "$stage/app/"
rsync -a --exclude .git --exclude '.env*' public/ "$stage/public/"

Stage by copying, rsync or cp -p, which keep the executable bit and modification times: Caddy derives a file's ETag from its size and date. Never stage by extracting a tar on macOS, which writes non-ASCII names decomposed: café.txt would reach the machine as another name than the one the site links to.

Then write the archive in pax, without the workstation's extended attributes, flags or owners. With macOS's tar, bsdtar:

COPYFILE_DISABLE=1 tar -czf "$stage/bundle.tar.gz" --format=paxr --no-xattrs --no-acls --no-fflags --uid 0 --gid 0 --uname root --gname root -C "$stage" app public

With GNU tar:

tar -czf "$stage/bundle.tar.gz" --format=pax --pax-option=delete=atime,delete=ctime,delete=mtime --owner=0 --group=0 --numeric-owner -C "$stage" app public

Name only the trees the project has: public alone for a static site, app alone without publicDir. Treat any warning tar prints as a failure, even when it exits 0: it may have left a file out. Not --format=ustar: macOS's tar drops a name over 100 bytes or a path over 255 with a warning, and exits 0. Not a bare --format=pax either: macOS's tar writes extended attributes in records that are not UTF-8, which the machine refuses, and both tars give every entry an extended header, which takes the machine's room for 20,000 entries. The forms above write one only where a long or non-ASCII name needs it.

The machine extracts the archive strictly:

  • pax, ustar or GNU tar, every pax value in UTF-8;
  • regular files and folders only: no symbolic or hard link, device or pipe, so replace a link with the file it points to;
  • every path under app/ or public/, a leading ./ dropped: no absolute path, no .., no empty or . component, no backslash, no file at the top level, each path once;
  • a path of 1,024 bytes at most, each component 255;
  • 100 MiB compressed, 512 MiB extracted, 20,000 entries, files and folders together.

bash deploy.sh --archive bundle.tar.gz writes the archive the script would send, and sends nothing: tar -tzvf bundle.tar.gz lists it.

api -X PUT -H 'Content-Type: application/gzip' --data-binary @"$stage/bundle.tar.gz" "$SITESOLIDE_API/api/v1/deployments/<id>/bundle"

It answers 202, the deployment running. A refused upload may have ended the deployment: read it before sending the archive again. Still awaiting-bundle, the dashboard's busy with three deployments running on the machine, send it again in a minute, within the 15 minutes. failed, a refusal from the machine itself, its own busy among them, start again from the manifest: the archive sent again would get 409 invalid.

api "$SITESOLIDE_API/api/v1/deployments/<id>?after=0"
{"deployment":{"id":"<id>","state":"succeeded","log":["..."],"next":42,"error":null,"url":"https://<slug>.<zone>/","allocated":[{"service":null,"port":3002}],...}}

Print log, pass next as the next after, and ask again about every second, until state is succeeded, failed or expired. A dropped connection ends nothing: ask again. Then remove the stage, rm -rf "$stage".

stateMeans
awaiting-bundleThe archive has not arrived.
runningThe machine installs it: keep reading.
succeededLive at url. allocated lists the ports the machine chose, service null for a single start: write them into sitesolide.json. missingSecrets lists the declared names and files the store lacked: a missing secret never fails a deployment.
failederror.code names the step that stopped it, error.message what to do.
expiredThe archive never came within 15 minutes, or a newer deployment replaced this one: start again.
RouteWhat it does
GET /api/v1/whoamiThe token's holder, scope and projects.
POST /api/v1/deployments { manifest }Judges the manifest, opens a deployment for 15 minutes.
PUT /api/v1/deployments/<id>/bundleThe archive; starts the installer.
GET /api/v1/deployments/<id>?after=<n>state, log from line n, next, error, url, allocated.
GET /api/v1/projectsThe projects the token reaches, with their status.
GET /api/v1/projects/<slug>One of them.
GET /api/v1/projects/<slug>/logs?lines=<n>&cursor=<c>The journal, 100 lines by default and 500 at most, and a cursor for the next call.
POST /api/v1/projects/<slug>/restartRestarts the service and watches it eight seconds: verdict.kind is active, looping or failure.
GET /api/v1/projects/<slug>/storeThe project's secret names, where each value comes from, what is missing, its files and its build's names, never a value. Needs Manage secrets.
PATCH /api/v1/projects/<slug>/store { set, remove, restart }Sets and removes variables, all or nothing; 201 when it created the store. Needs Manage secrets.
POST /api/v1/projects/<slug>/store/generate { name, format, bytes, replace, restart }Draws a random value on the server, never returned. Needs Manage secrets.
PUT /api/v1/projects/<slug>/store/files/<name> { content, restart }Sets a file read whole, its bytes in base64. Needs Manage secrets.
DELETE /api/v1/projects/<slug>/store/files/<name> { restart }Removes that file. Needs Manage secrets.
GET /api/v1/projects/<slug>/accessGeneral access, people with access, and the domains the token may give.
PUT /api/v1/projects/<slug>/access { who }Gives Can open to a company email, or to one of the company's @domains.
DELETE /api/v1/projects/<slug>/access { who }Takes a Can open entry off.

A value never goes on a command line, and no route reads one back. The token needs Manage secrets, and may set any name of the project's store, declared in sitesolide.json or not. Read the value without echo, then hand it to jq on its standard input:

IFS= read -rs VALUE
printf '%s' "$VALUE" | jq -Rs '{set: {API_KEY: .}, restart: true}' | \
  api -X PATCH -H 'Content-Type: application/json' --data-binary @- "$SITESOLIDE_API/api/v1/projects/<slug>/store"
unset VALUE

It answers 200, or 201 when the change created the store:

{"store":{"slug":"<slug>","variables":[{"name":"API_KEY","source":"store",...}],"missing":[],...},"created":false,"set":["API_KEY"],"generated":[],"removed":[],"absent":[],"files":{"set":[],"removed":[]},"restart":{"verdict":{"kind":"active",...}}}

A random key the app checks itself is better drawn on the server, where nobody sees it; a name already set is refused unless replace:

api -H 'Content-Type: application/json' -d '{"name": "SESSION_SECRET"}' "$SITESOLIDE_API/api/v1/projects/<slug>/store/generate"

A file goes whole, its bytes in base64, and each service reads it at $CREDENTIALS_DIRECTORY/files_<name>:

base64 < gcp.json | tr -d '\n' | jq -Rs '{content: .}' | \
  api -X PUT -H 'Content-Type: application/json' --data-binary @- "$SITESOLIDE_API/api/v1/projects/<slug>/store/files/gcp.json"

Never a value read from the conversation. Names alone carry no secret: {"remove": ["OLD_KEY"]} goes on the line. A value holds on one line, 8 KiB at most; a file 64 KiB, and a project 32 files. Removing a name that a secret file from before still holds is refused, since that file's value would come back: the owner moves those files to the store. The route of before, PATCH /api/v1/projects/<slug>/secrets/<slug>.env, serves only a project whose manifest still declares <slug>.env.

Every refusal is {"error": "<code>", "message": "<what to do>"}, with details listing every reason a manifest is refused, and wait, in seconds, when the answer knows how long.

CodeStatusWhat to do
unauthenticated401The token is missing, unknown, expired or revoked: ask the owner, or mint another if it is your own.
out-of-scope403The token may not do this, and the message says whom to ask. Never try another token.
reserved403The slug belongs to the platform, starts like one of its projects (dashboard-*, portal-*), or is a removed project's whose backups or connector grants the machine still keeps: pick another.
invalid-manifest422Fix every point of details, then send the manifest again.
invalid400, 409The request is malformed, an access who that is neither an email nor a @domain or a secret the rules above refuse among them, or the archive went to a deployment that no longer waits for one: the message says which.
not-found404No such deployment or project for this token, or a project not deployed yet.
busy409A deployment of the project runs, or the machine runs three: try again in a minute, after reading the deployment on an upload.
unmanaged409The project's store, one of its files or a secret file from before is not in a form the server rewrites: the owner fixes it on the server.
already-present409Generating a name already set: replace draws a new one, and the old value stops working at the next restart.
migrate-first409The manifest's units would stop reading a secret file from before whose values the store lacks: the owner merges it into the store first, sitesolide secrets migrate --restart, then deploy again.
expired410The archive came after 15 minutes: start again from the manifest.
too-large413A manifest over 64 KiB, or an archive over 100 MiB: exclude dependencies and caches. A change of secrets over 512 KiB: split it.
too-many-attempts429Wait wait seconds. Without wait, the message says what to wait for, the hour's changes of access among them. Never retry in a loop.
not-available503The machine does not carry this route yet: tell the owner, who runs sitesolide upgrade.
failure500, 502Something broke on the machine: tell the owner, with the message.

A failed deployment's error.code names the step that stopped it, with a message that says what to do:

error.codeWhat to do
invalid-manifest, out-of-scopeJudged again on the machine, against its state and the token's scope at that moment: fix what the message names, or ask the owner.
secret-takenThe project, in the form of before, would read a secret file not its own: pick another slug, or tell the owner.
store-refusedThe project's store on the server is not a folder root owns: tell the owner, nothing was changed.
store-failedThe project's store could not be laid on the server, so nothing was served: tell the owner, who runs sitesolide upgrade.
store-not-yoursA first deployment found the project's store made by another's write, never this token's: nothing was served, tell the owner.
taken-meanwhileThe project was deployed on the server after this token's creation of it was accepted: pick another slug, or ask the owner.
migrate-firstJudged again on the machine before any unit is laid, as above: nothing was changed.
bundle-refusedThe archive broke a rule above: the message names the entry.
public-emptypublicDir arrived empty: make the build fill it.
install-failedinstall failed on the machine: run it in the project's folder and fix what it reports.
port-taken, domain-takenDrop port to let the machine choose one; a domain belongs to one project.
service-failedThe service did not start, or fell within ten seconds: read its logs. A service that could not load its secret files says so: the owner runs sitesolide refresh <slug>.
verify-failed/ must answer 200, or the portal's 401 on a Restricted site.
system-unitThe slug names a service of the machine: pick another.
caddy-busy, access-changed, machine-unreadableSomething changed on the machine meanwhile: deploy again in a minute.
no-port, edited-by-hand, portal-not-ready, caddy-unknown, caddy-refused, loopback-outdated, loopback-failed, misconfigured, no-resultTell the owner, with the message: only they can fix it.
Any otherTell the owner, with the message and the deployment's id.
In sitesolide.json, or on the machineRefused unless
portalExemptThe token may deploy public sites.
A static site, with neither start nor servicesThe token may deploy public sites.
A project public on the machineThe token may deploy public sites. A person's own token deploys it as it stands.
"network": "outbound", or egressThe token may use outbound network.
domainThe token may declare a domain.
secrets in the form of before naming a file other than <slug>.env, lockNever.
memory above 1G, more than 6 servicesNever.

A manifest without portal is not refused: a new project is Restricted unless the token may deploy public sites. A project that already exists keeps the general access the machine carries: it changes from the dashboard, never from a deployment.