Recipes
A recipe describes one piece of software: where to get it, how to install it, how to run it, how to tell whether it is healthy, and what it needs from other components.
This page is about what the fields mean. If the TOML itself is what you are
fighting ā why [[artifacts]] has two brackets, or why a misspelled field does
not raise an error ā start with the
TOML cheat sheet.
flowchart LR
R["recipe"] --> M["metadata"]
R --> A["artifacts"]
R --> I["lifecycle.install"]
R --> RUN["lifecycle.run"]
R --> D["dependencies"]
RUN --> S["run.security"]
RUN --> H["run.health"]Like you’re five
A recipe is a cooking recipe. It says what to buy (artifacts), how to prepare it (install), how to cook it (run), how to tell it is done (health), and what else must be ready first (dependencies).
Metadata
name and version together are the recipe’s identity. Keystone uses that
identity, plus a digest of the file, to decide whether a component changed when you
re-apply a plan.
Artifacts
Files to download before the component can run.
Downloads resume, retry with backoff, and are cached under
runtime/artifacts/<name>/<version>/. Both the SHA-256 and the signature are
mandatory unless the agent runs with --insecure-skip-verify. Details in
Artifacts.
Without unpack, the file appears in the working directory under its name, as a
hard link to the cached copy. Treat it as read-only: an install hook or a
component that needs to change it copies it out first. Writing into it in place
changes the cache too: the next start downloads the artifact again if the recipe
declares its sha256, and keeps the changed file if it does not.
Patching instead of downloading
On a slow or metered link, an artifact can be updated by patching the version the device already has:
Between two adjacent Keystone releases that turns a 13.4 MB download into 1.0 MB. The saving is not fixed ā a release that changes the Go toolchain is nearer 6 MB.
The block is optional and additive:
- Nothing else changes. The publisher keeps publishing the same
.tar.gz; the patch is computed over its uncompressed form, which only exists on the device. - It always falls back. First install, no patch on the server, a patch that does not apply, a digest that does not match ā all of them download the whole artifact instead of failing the apply.
- It is safe to roll out gradually. An agent older than this field ignores it and downloads normally, so a recipe carrying a delta block can go to a fleet of mixed agent versions without coordinating an upgrade.
The sha256 inside the block is what the patched result is checked against, and it
is trusted because the recipe itself is signed. How that changes who attests to the
bytes, and the current limits, are in
Artifacts.
Lifecycle: install
A shell script run once in the component’s working directory
(runtime/components/<name>/<version>/). A marker file makes it idempotent: it
will not run again on the next apply unless the version changes.
Lifecycle: run
Restart policies:
| Policy | Behaviour |
|---|---|
always | Restart on any exit, and also when the health probe fails past its threshold |
on-failure | Restart only on a non-zero exit. A clean exit is left alone (the component becomes stopped) |
never | Never restart |
Restarts back off exponentially (1 s doubling to 60 s, ±25 % jitter) so a crash-looping component cannot saturate the device.
Lifecycle: run, in a container
See Containers for how the runtime is chosen.
Reaching another component by name
A container started by Keystone is named keystone-<component>-<timestamp>.
The timestamp is there so a restart never collides with a container still being
removed, which also means the container name is useless as an address: it is
different on every start.
The stable name is the network alias, and by default it is the component name ā the same thing a compose service name gives you:
Declare network_aliases only when the DNS name has to differ from the
component name:
Two limits, both enforced rather than papered over:
- The network must be user-defined.
bridge,hostandnonehave no embedded resolver, and the CLI rejects an alias there. Keystone refuses the recipe instead of emitting a flag that fails at run time. runtime = "containerd"cannot do this. CNI knowshost,bridgeandnone, and has no equivalent of a network alias. A recipe that asks containerd for either is refused ā without the check it would fall through every branch and start the container on an empty network namespace, with no error at all. Underruntime = "auto", a user-defined network or an alias selects the CLI runtime.
Keystone does not create the network. It is machine preparation, not
deployment: create it with your configuration management (docker network create solver-net) and Keystone will use it. A plan naming a network that does
not exist is refused before the container is created, naming the network and
the command that would create it ā rather than after the image has been pulled,
which is where the runtime would have reported it.
A note on hostname, because the distinction is easy to get backwards: inside
a user-defined network, Docker’s embedded resolver answers to the container
name, the alias and the hostname. On the default bridge it answers to
none of them. So the line that matters is the network, not which field you set
ā and the alias is still the right one to use, because a container has exactly
one hostname and any number of aliases, and the alias is the one Keystone keeps
stable for you.
Lifecycle: health
Declaring a health check changes two things: the component is only considered ready when it first probes healthy, and it is only eligible for reuse on a re-apply while it is healthy.
Without a health check, last_health stays unknown forever. That is expected.
Probing a container with no shell
check = "cmd:ā¦" is a shell command line: it runs through /bin/sh -c. An
image built FROM scratch has no /bin/sh, so that probe fails every time ā
and a health probe that never passes takes the deployment down with it, because
a failed probe rolls the plan back while reporting that it did the right thing.
Use exec instead, which is an argv handed to the workload directly:
It is the equivalent of compose’s test = ["CMD", "/rotaflux", "healthcheck"].
check and exec are two ways of saying the same thing, so declaring both is
refused. exec works for process components too, where it means “run this argv
on the host, without a shell in between”.
Lifecycle: state version
One number, answering one question: is it safe to roll this component back?
Keystone’s rollback reverts binaries and images. It never touches what a component wrote. So for a component that migrates its data on startup and cannot go back, reverting the binary is not a recovery ā it is a second failure, on top of the first, in a shape that looks nothing like a deployment problem.
Raise the number in the recipe of the build that introduces a migration; leave it alone for a build that reads everything its predecessor wrote. Before rolling back, the agent compares the two plans and refuses to cross a migration backwards. The details, and what the comparison deliberately does not do, are in Plans and rollback.
Lifecycle: shutdown
Best-effort hook run when the component is stopped. Failures are logged, not fatal.
Security
Process components only. The equivalent of a systemd unit’s User=,
NoNewPrivileges= and AmbientCapabilities=:
Anything declared here is enforced or the component refuses to start. Full semantics in Process privileges.
Resources
open_files is set on the component’s own process, never on the agent.
memory_limit and cpu_quota under [resources] are refused: a recipe that
declares either fails to apply, naming them. They used to be accepted and applied
to nothing, for every component type, so a recipe could declare a 256 MB limit
and run unbounded.
For a container, limits go in [lifecycle.run.container.resources], and
every field there is applied under both containerd and the CLI runtimes:
memory_mb, memory_swap (MB, memory plus swap, -1 unlimited; needs
memory_mb), cpu_shares, cpu_quota and cpu_period (µs; the period
defaults to 100000 and needs a quota), pids_limit.
For a process, there are no memory or CPU limits: run it as a container, or place it in a systemd slice.
Dependencies
| Type | Must be in the plan? | Restarted when the dependency restarts? |
|---|---|---|
hard | yes | yes |
soft | no | yes, if present |
ordering | yes | no |
ordering is the one people forget: use it when B must start after A but does
not care if A is later restarted. See Dependencies.
Signing
A recipe loaded from a file must carry a detached signature
(<recipe>.toml.sig) verifiable against the trust bundle, checked before any
hook runs. Recipes pushed through the authenticated API are trusted by that
authentication instead. See Signing.