Tooling · for everyone, before a deploy · Markdown

304. Checking a deployment

The question this chapter answers: what is wrong with an application that starts, serves and answers, but does not do what its configuration says it does?

What the host already refuses

The host is strict at boot on purpose. A declared action nothing answers, a bundle carrying a server module, a route claimed twice, a plan file it cannot read, a [server] render line: each of those stops the process with the reason, because a boot failure is easier to notice than a deployment that partly works.

Everything the host catches is a contradiction it can see from inside a single boot. It cannot catch a setting that is coherent, loads cleanly and still cannot do the job it was written for. A locale in the table with no catalog file loads: t falls back and the page renders in the wrong language. An import map naming a package nothing vendored loads: the browser asks for the file and gets a 404. A plan older than the routes it was lowered from loads cleanly, then answers the old routes.

fsr doctor checks for those.

text
fsr doctor app

It reads the configuration and the plan the way the host would, runs every check and prints what it found with what to do about it. Nothing found is exit 0. Anything found is exit 1, so a deploy script can stop on it:

sh
fsr build app && fsr doctor app

fsr bundle runs the same checks itself and refuses on a finding, so a deploy that ends in a bundle needs no separate step:

sh
fsr build app && fsr bundle app

Run the command on its own in CI, where there is a plan to check but no tree to write. fsr bundle --no-doctor skips the check for a caller that means to bundle anyway.

What it checks

Each check answers from something a build already computed, so none of it needs a server, a browser or a network.

CheckWhat it reportsWhy it matters
canonical[document] origin is unset while the deployment names hosts or has a route to prerenderWithout an origin the canonical and alternate links are relative, which an audit reports and a crawler resolves against whatever host it arrived on
prerender[server] prerender names a directory and no route is prerenderableEvery route reads the session, the identity, the query, the params or the time, so the host would write nothing there; the setting is left over from an application that did prerender
ctx.hosta body reads ctx.host while [server] hosts is emptyThe list is the only opt-in, so an empty one means the read answers null for ever rather than the host the request carried
ctx.configa body reads ctx.config.<key> while [public] does not declare the keyThe declaration is what types the read and what an overlay sets, so a key missing from it answers null on every deployment
localesa locale in [locales] supported with no catalog under locales/The application says it serves that language and every message falls back
stalethe plan is missing or older than routes/, src/, clients/ or schemas/The host reads the plan and never the sources, so an unbuilt change is invisible until the next build
vendorthe import map names a package with nothing under vendor/ to answer itThe browser asks for the file the map names, so a missing one is a page that does not mount
staticsa [[static]] root whose directory is not thereEvery path under that route answers 404, including the client bundle when it is served that way
shadowa [[static]] root whose route swallows an application routeA matched static prefix answers from the directory and returns, so the page underneath it never runs
bearera client carries a bearer token while [auth] is unsetAn [auth] provider is the only thing that writes a token into custody, so the call goes out with no Authorization header
cache.tagsa call drops a cache tag no cached method namesThe two sides are strings that have to agree; a typo either way leaves a write that invalidates nothing
linksa literal internal link matching no route, declared Rust route, static root prefix, file under the root at / or mounted siteThe plan already holds the link and the routes, so a 404 you would otherwise find only by clicking shows up without a crawler
rendera [server] render lineEvery page renders on the server and every island in the browser, so no host accepts the setting and the boot stops until the line is taken out
treea file a deploy tree would carry that the project does not hold; a setting no tree can expressThe host reads each of these at boot, so a tree without one starts on the machine that built it and fails on the machine it was copied to
cspa policy naming 'strict-dynamic''strict-dynamic' makes the browser ignore 'self' and every host, leaving the entry module with nothing to allow it
runtimewith --runtime, a value the deployment must hold that is unset or still the scaffold's placeholder, session.key among themThe host refuses to start without it; on the machine that will run it, doctor says so before the deploy swaps over
sectionsa top-level section the host does not read whose name is one letter from one it does, [sesion]Any other section belongs to the application's own store, so the host loads a misspelt one without a word and the setting it held never applies
sitesa mounted site that pins no hash, ships a part the artifact does not carry, has no plan or one older than its own routes, plus artifacts under the root no mount namesA shell serves a site it never builds, so nothing about the artifact is checked until a request asks for it

A report names the check, the fact and the remedy:

text
canonical    `document.origin` is unset while `server.hosts` names 2 hosts, so every canonical and alternate link is relative
             set `[document] origin` to the address this deployment is reached at, `https://example.com`
doctor       1 of 16 checks found something

Plan and configuration checks

Four of the checks compare two artifacts that were written separately and have to agree.

shadow is the one with a boot error beside it. The host refuses to start when two plans claim one route and says nothing when a static root claims one, even though the outcome is worse: a matched static prefix answers and returns rather than falling through, so the page under it is unreachable for as long as both are declared.

bearer and cache.tags are both a name that only works if two places spell it the same. A bearer client reads a token out of custody and an [auth] provider is the only thing that puts one there. A cache tag is dropped by whatever names it in writes. Only the write side of a tag is asked about, because a cached tag nothing writes is how a read-only service says it expires by its own ttl. A typo in either direction leaves a written tag nothing caches.

links reads the literal href of every anchor the build lowered and asks whether this deployment answers it, against its routes, the routes [[route]] declares for the ones Rust registers, its static roots, the prefixes of the sites it mounts and the framework's own paths. A locale prefix is stripped first, the way the host strips one. Only a literal is asked about, since the build cannot know every value a computed href takes. A site is exempt: its links reach into a shell it cannot see, so the shell it was built against need not be the one it runs in.

Deploy tree checks

The other checks read settings. This one reads the deploy tree that fsr bundle is about to write, before it exists, then reports a file the tree would name and the project cannot supply.

That is a class of failure the other checks cannot see, because the files it covers are not named in any setting. The host imports each service client's document at boot and will not start without it. It reads locales/ by name and an application whose catalogs went missing serves message keys. Neither is a path anybody writes down, so the only way to check them is to derive what the tree holds from what the host reads, which is what the bundle does anyway. Chapter 303 describes that derivation.

The check also refuses a setting no tree can express. A [[static]] route that climbs out of its own prefix has no place to land under serve/, so the layout stops rather than picking one.

The plan and the import map are left to stale and vendor, which already report them with remedies of their own.

Site checks

A shell serves a mounted site and never builds it, so the artifact is the only thing that says what it should carry.

A mount that pins no hash accepts whatever sits at the path. The pin is what makes a deploy reproducible; fsr sites hash <site dir> prints the one to set. Only a name@version artifact is asked for one: a mount naming a path is a linked working tree that changes on every build, so a pin there would be stale by the next one.

A part the artifact says it ships and does not carry is hashed as absent rather than refused, so the site mounts and then answers 404 for its own assets. That happens when a site is packed without being rebuilt.

Artifacts under the sites root that no mount names are what fsr sites install leaves behind. They cost disk and they make it hard to tell which version is live; --keep <n> bounds them.

A pin the artifact does not match is reported with what to do about it. The content changed. If that was meant, fsr sites pin records it. If it was not, the directory is not the version the shell pinned. A mount pointing at nothing is reported too. Both are refusals to start rather than warnings; doctor reports them before the deploy and the host still refuses them at boot.

Checking one deployment

The configuration a deployment boots with is a ladder of files merged in order: app.toml, then the release environment, the application environment, the region and the two together. Doctor merges the ladder the environment names, which on a build machine is usually development. Name the deployment to check its ladder instead:

sh
fsr doctor app --env production --app-env eu --region sfo1

Each flag stands in for the variable the host reads, RELEASE_ENV, APP_ENV and APP_REGION, so the files doctor merges are the ones that deployment boots with. A key an overlay misspells or gives the wrong type stops it the way it stops the host.

Some values arrive only when the deployment starts, set in its environment rather than written in a file: session.key is set by C5_SESSION__KEY, a secret no repository should hold. A build machine does not have them, so doctor lists each one the files leave unset as an expectation, with the variable that must set it, rather than a finding:

text
expects      session.key: the files hold the placeholder; this deployment needs C5_SESSION__KEY when it starts
doctor       21 checks, nothing to report

A secret written encrypted in an overlay (a .c5encval value) is in the files but only decrypts with the deployment's key, which a build machine does not hold either. Doctor lists it the same way: session.key: encrypted in the files; this deployment needs the key that decrypts it when it starts. fsr bundle lays out such an overlay without decrypting it.

On the machine that will run it, with its environment in place, add --runtime and the same value is a finding when it is still missing:

sh
fsr doctor app --env production --runtime

The schema an editor checks against

fsr build writes generated/config.schema.json, a JSON Schema of every section the host reads, typed from the code that reads it, so the schema and the host cannot disagree. A project from fsr new names it on the first line of each configuration file, which an editor with a TOML language server (taplo, Even Better TOML) reads to complete keys, describe them and underline one no section declares:

toml
#:schema ../app/generated/config.schema.json
[session]
ttl = "24h"

A value the deployment's environment sets carries the variable that sets it, x-runtime: C5_SESSION__KEY. A table the host does not read is allowed, since it is the application's own.

What doctor does not do

It never fixes anything. Every finding here has a remedy that is a judgement: whether a locale should gain a catalog or leave the table, whether a link is wrong or a route is missing. So doctor reports the finding and leaves the choice to you.

It never softens a boot error. Anything the host refuses to start over still fails at boot. Doctor reports what has nowhere else to be reported; it does not downgrade failures.

It only checks contradictions. Every check covers a fact the application stated and then contradicted, never a matter of taste. "The locale table names fr and there is no fr catalog" is a fact. Style questions such as how long a title should be are not checked. There is no configuration file for turning checks off.

Where it belongs

Run it in CI after the build, then in a deploy script before the bundle. It is fast and needs nothing running, so it is the last thing that reads the whole configuration before a server does.