convergent machine-state management: set up and ship to Linux machines from TypeScript ↯
Introduction #
Zap sets up and ships to Linux machines you own, from a TypeScript file.
You have a few Linux machines, and each one has to be set up a particular way
and kept that way as its needs change. With Zap you declare what should be true
of a machine: these packages installed, this file with this content, this user
with these SSH keys, this service running, this firewall. Zap reads the machine,
shows you exactly what it would change, and changes nothing until you pass --wetrun. Run it again tomorrow and it touches only what drifted. The same
tool then ships your app to that machine as a versioned release with a health
check and rollback.
If you know Terraform, Zap brings its plan-then-apply discipline to the inside of a machine. If you know a virtual DOM, it works the same way: you describe the whole result, and it works out the smallest set of changes to get there. Unlike both, Zap keeps no stored copy of the machine: it diffs against the machine itself, read fresh on every run, so a change someone made by hand shows up in the plan.
Zap is a Rust binary that works over SSH or on the machine it runs on, with no
agent on the host and no server. It evaluates your zap.ts with fgro, Gro's
sandboxed JS runtime (install.md).
Who it's for
- You run a few Linux machines by hand. A laptop, a VPS or two, a home server. You want them described in one place and set up the same way every time, without learning a fleet tool.
- You ship your own services. A web app behind nginx with PostgreSQL on the box, deployed as release directories with an atomic swap, where the deploy refuses to ship onto a host whose config has drifted.
- You let an assistant drive. An AI agent, or a teammate who isn't an ops
engineer, runs the tool for you. Planning changes nothing, so they can do it
freely; you review the plan, and a wetrun executes only that plan. Every run ends
in an exit code that says what was established, and
--jsonstreams a wetrun as typed events rather than free-form logs.
What a run looks like
// zap.ts
export default (zap) => {
zap.target({local: true, package_manager: 'apt', service_manager: 'systemd'});
zap.pkg({id: 'ripgrep', from: {apt: 'ripgrep'}});
zap.file({
id: 'gitconfig',
path: '~/.gitconfig',
content: {type: 'inline', content: '[user]\n\tname = Ada\n'},
});
};$ zap apply
apply — target local (dry-run)
user: ada
home: /home/ada
target: {"local":true,"package_manager":"apt","service_manager":"systemd"}
[+] cache-update-apt (script)
check: true
> sudo apt-get update
[+] gitconfig (file)
path: ~/.gitconfig
content: inline (19 bytes, sha512 0435b012ac1a81df…)
[+] ripgrep (package)
> sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ripgrep
(dry-run — re-run with --wetrun to apply)
plan_hash: d317a15e82f2f1117f0d9fa1fdafc079b70f9e3ac5e3103c4b3333f1b6afeab0
$ zap apply --wetrun
apply — target local (wetrun)
user: ada
home: /home/ada
target: {"local":true,"package_manager":"apt","service_manager":"systemd"}
ok cache-update-apt
ok gitconfig
ok ripgrep
applied: 3 ok, 0 failed, 0 skipped (of 3)
plan_hash: d317a15e82f2f1117f0d9fa1fdafc079b70f9e3ac5e3103c4b3333f1b6afeab0The cache-update-apt change is the package index refresh Zap puts in front of
an install. The wetrun planned again, got the same plan_hash the dry run
printed, and only then acted. Had the machine changed in between, it would have
shown the new plan and refused it, or, on a terminal, asked before running it.
Point the target at a host instead and the same config converges a server over SSH. Getting started walks through both.
What Zap does differently
- A wetrun executes only the plan you reviewed. Dry run is the default,
with no flag to auto-approve.
--wetrunre-plans against the live machine, then refuses unless the plan matches the one you saw, by hash; on a terminal, when the plan isn't the reviewed one, it shows you the plan and asks first. - Unknown means hold, not guess. A resource Zap can't read is left out of
the plan and reported as undetermined, with exit 1. It never reads as
"converged" because a probe failed. (A few kinds whose create step is
idempotent read a failed probe as
absent; see detection.md.) - Every run reads the live machine. No detection cache, no stored copy of the machine. The security controls (firewall rules, SSH keys, sudoers, database auth) are read live every time, because they are what gets changed by hand.
- Apply checks its own work. After acting, Zap reads the machine again and fails the run if anything didn't converge.
- The config can only describe.
zap.tsruns in a sandbox with no network, no writes, no environment, no clock, and emits a JSON declaration. Secrets are references, resolved by Zap when it acts and masked everywhere else.
Plus: a reload a failed run owed is remembered on the host and re-fired, and fetched content is pinned by hash in a lock file next to the config.
What Zap converges
- Software: apt and apk packages, apt repositories, and from-source builds pinned to their source archive.
- Files: files (inline, templated, copied, or fetched and pinned), directories, and symlinks.
- Users: shell, home mode, SSH keys, sudoers, and groups.
- Services: systemd and OpenRC, with restart and reload triggers.
- PostgreSQL: databases and roles, with
pg_hbarules. - System: swap files and firewalls (ufw, nftables).
- Everything else: idempotent scripts, installers, assertions, and pipelines.
The full reference is resources.md.
Hosts: Debian/Ubuntu and Alpine. Zap manages real servers in both families, and the firewall, user, and database controls are read live on both.
What Zap is not
- Not a cloud provisioner. No VPCs, load balancers, DNS, or managed databases, and no provider ecosystem. Zap converges the inside of machines you already have. Pair it with OpenTofu or Pulumi for the outside.
- Not an orchestrator. No data-dependent branching mid-run. A
script's shell can do anything, but Zap doesn't model it. - Not a fleet tool. One host per config, resources applied in order. Loop a shell over configs for a handful of hosts; reach for Ansible or pyinfra for hundreds.
- Not an agent platform. Nothing runs on the host between runs.
- Not a remover. Delete a resource from the config and the thing stays; Zap reports it as orphaned. Declare removals explicitly.
- Not macOS or Windows. Zap manages Linux hosts only.
The full picture, against each alternative: comparison.md.
Status
Zap is pre-alpha and has one maintainer. It runs the servers of the project it comes from and the maintainer's own machines. The resource set is small and fixed, there is no plugin system, and the config schema will change before 1.0 without a compatibility layer. Read security.md before pointing it at a machine you care about, and faq.md for the questions that come up first.
Where next
- install.md, then getting-started.md
- concepts.md: the model, in one page
- configs.md: writing
zap.ts - cli.md: every command and exit code
Install #
Zap is built from source, and so is fgro, Gro's runtime, which evaluates your
TypeScript config. Zap never runs a config without it.
What you need on your machine
| Tool | Why | Check |
|---|---|---|
Rust via rustup (it installs the toolchain pinned in rust-toolchain.toml) | to build the zap binary | cargo --version |
C toolchain (cc; on Debian, build-essential) | Rust links with it | cc --version |
| fgro, Gro's runtime | every command that reads a config evaluates zap.ts with fgro eval, in its kernel sandbox | fgro --version |
| Linux with Landlock ABI 6 or later (6.12) | the sandbox fgro evaluates in | uname -r |
| ssh | remote targets | ssh -V |
| scp | a remote archive or a build source on a remote target | |
| git, tar | zap deploy | |
| minisign | only for a build source with verify; on PATH, or named by ZAP_MINISIGN | |
| Node and npm | only to import the shipped recipes and get editor types | npm --version |
Build
Zap depends on crates from the sibling fuz repository, resolved by relative
path, so the two checkouts sit next to each other:
git clone https://github.com/fuzdev/fuz
git clone https://github.com/fuzdev/zap
cd zap
cargo xtask installcargo xtask install makes a release build and puts the binary at ~/.zap/bin/zap. Add that directory to your PATH:
export PATH="$HOME/.zap/bin:$PATH" # bash / zsh
fish_add_path ~/.zap/bin # fishThen:
$ zap version
zap <version>Debug and static musl builds are in contributing.md.
The binary on your PATH doesn't update when you pull. Run cargo xtask install again after pulling changes.
Getting fgro
Build fgro from a checkout of Gro, on x86_64 Linux. The build downloads and
verifies a pinned V8 first, and the binary must carry its startup snapshot,
which cargo xtask build embeds:
git clone https://github.com/fuzdev/gro
cd gro
cargo xtask fetch-v8
cargo xtask build --releaseThe binary is target/gro_snapshot/release/fgro. Don't point ZAP_FGRO at it there:
each build replaces it, and a project's zap.lock pins the exact binary its
configs are evaluated with (lock.md), so a
rebuild would make every pin stale. Copy it to a place you update deliberately,
and name that copy:
install -D -m 755 target/gro_snapshot/release/fgro ~/.zap/bin/fgro
export ZAP_FGRO="$HOME/.zap/bin/fgro" # bash / zsh
set -gx ZAP_FGRO ~/.zap/bin/fgro # fishZap takes the binary by absolute path, from --fgro <path> or ZAP_FGRO, and
never looks it up on PATH. sudo resets the environment, so under sudo only --fgro reaches Zap: sudo zap apply --fgro ~/.zap/bin/fgro. Name the
binary itself, not a link to it or a wrapper script.
Then pin it in each project: zap lock evaluates the config with that binary
and records its blake3 in zap.lock; commit the lock. When you update the copy,
run zap lock again in each project.
Zap and fgro talk through a versioned eval protocol. An fgro that answers in a
version this zap doesn't read is refused, exit 2, whatever the pin says: update
the older of the two, then zap lock.
Types and recipes for your config
A config needs a project of its own: a package.json beside it, or in a
directory above it that you own and no other user can write. One holding only "type" or "sideEffects" doesn't count; a "name" is enough. A .js module
the config imports needs "type": "module":
{"name": "infra", "private": true, "type": "module"}A bare config needs nothing else installed:
export default (zap) => {
zap.target({local: true});
// …
};For editor completion and type checking, import the builder type. The import is type-only, so the evaluator strips it and needs nothing resolvable at run time:
import type {ZapBuilder} from '@fuzdev/zap/zap.ts';
export default (zap: ZapBuilder) => {
// …
};To use the shipped recipes (nginx sites, hardened systemd units, SSH
hardening, certbot, firewalls), your project needs the @fuzdev/zap package resolvable
under node_modules/@fuzdev/zap. The package isn't published to npm; point a path
dependency at your checkout:
{
"devDependencies": {
"@fuzdev/zap": "../zap"
}
}npm install then symlinks the checkout into node_modules. That link, in the
config's own project, is the one way its import of @fuzdev/zap resolves: a node_modules above the project is never searched (security.md). The
checkout must not sit under a directory every user can write. fgro resolves
recipe imports through the package's exports, which carry a gro condition
pointing at the TypeScript sources, so a recipe edit in the checkout reaches
your next plan with no build step. TypeScript and your editor resolve
the same imports through the built dist/, so run npm install && npm run build in the zap checkout once (and after pulling) to
keep types current.
import type {ZapBuilder} from '@fuzdev/zap/zap.ts';
import {ssh_hardening} from '@fuzdev/zap/recipe_ssh.ts';See recipes.md for the catalog.
What a target needs
A local target needs sudo only if the config touches files you don't own or
runs privileged commands. A remote target needs sshd, a POSIX shell with
coreutils or BusyBox, and a sudo that works without a password for the user
Zap connects as. Details per distro: targets.md.
Uninstall
Remove ~/.zap (the binary and, unless ZAP_HOME moved them, the caches,
last-run reports, deploy previews, SSH sockets), and $ZAP_HOME if you set one;
a zap run under sudo keeps its own, in the home sudo gives it (root's ~/.zap). Zap keeps no other state on your machine. On a target it
leaves only what your config declared, plus any pending-trigger markers under /var/lib/zap/pending/, a build's <output>.zap-build provenance sidecar,
an extracted remote file's .zap_remote_hash sentinel, and in a deploy's path, deploy_info.json and the .zap-deploy.lock file. A run killed outright
can also leave a .zap.* staged file beside a file it was writing, or a
release archive (.zap-deploy-<user>-<version>.tar.gz) in a deploy's path. A
killed deploy can leave releases/.zap-stage and releases/.zap-old-* there
too, which the next deploy to that path cleans up.
Getting started #
Two walks: your own machine first, then a server over SSH. Each takes a few
minutes. Install Zap and fgro before you start.
1. Your own machine
Make a directory for the config, give it a package.json, which marks it as the
config's project, and write a zap.ts that manages a directory and a file you
own, so nothing needs sudo:
{"name": "infra", "private": true}// zap.ts
export default (zap) => {
zap.target({local: true});
zap.directory({id: 'notes', path: '~/notes', mode: '755'});
zap.file({
id: 'hello',
path: '~/notes/hello.txt',
content: {type: 'inline', content: 'hello from zap\n'},
depends_on: ['notes'],
});
};A config is a function. It receives a builder and declares a target and
resources; it returns nothing. Every resource has an id you choose, which is
how other resources and Zap's output refer to it.
Pin the evaluator. zap lock records the fgro build that evaluates the
config in zap.lock; every other command that reads the config refuses until it does:
$ zap lock
pinned fgro c8a3d0effbcb7ca4540fa9197dfff499c385547b10cff6931644d6450446dcb7
wrote zap.lock (no eligible resources)Preview. zap apply without --wetrun reads the machine and shows what it
would change. It changes nothing, and exits 2 to say changes are pending:
$ zap apply
apply — target local (dry-run)
user: ops
home: /home/ops
target: {"local":true}
[+] notes (directory)
path: ~/notes
mode: 755
[+] hello (file)
path: ~/notes/hello.txt
content: inline (15 bytes, sha512 0c7463f032f1dc98…)
(dry-run — re-run with --wetrun to apply)
plan_hash: 5bb79cc9141942676c784abba55826f12be51b3feb86f5f0cf77666545674d61
$ echo $? # $status in fish
2[+] is a create, [~] an update, and the lines under each say what the change
declares: a file's content shows as its length and the start of its SHA-512,
never the bytes. The plan_hash names this exact plan. zap plan prints the
same plan with a change count and exits 0: it reports, where apply judges.
Apply. Now act:
$ zap apply --wetrun
apply — target local (wetrun)
user: ops
home: /home/ops
target: {"local":true}
ok notes
ok hello
applied: 2 ok, 0 failed, 0 skipped (of 2)
plan_hash: 5bb79cc9141942676c784abba55826f12be51b3feb86f5f0cf77666545674d61After the changes ran, Zap read both resources again and found them converged,
which is why the exit was 0. Had anything still read wrong, the report would
say RESIDUAL and exit 1.
The wetrun went through because your preview a moment ago bound it: a wetrun executes only the plan you reviewed.
Had you run zap apply --wetrun first, with no preview behind it, it would
have shown the plan and asked:
$ zap apply --wetrun
apply — target local (wetrun, awaiting confirmation)
…
UNREVIEWED — this plan is not the one that was reviewed.
no plan has been previewed on this host.
plan_hash: 5bb79cc9141942676c784abba55826f12be51b3feb86f5f0cf77666545674d61
Apply this plan? Type 5bb79cc9, the start of its plan_hash, to confirm: n
apply — target local (wetrun, declined)
…
DECLINED — nothing ran: this plan was not confirmed, and is not the one that was reviewed.
…Typing 5bb79cc9 would have run exactly that plan: the answer is the start of a hash
you can only know by looking at the plan. Declined, it changed nothing, exited 2,
and became the preview: running it again executes if nothing moved. Without a
terminal (a script, CI, < /dev/null) the wetrun doesn't ask; it renders the
same plan under a REFUSED banner and stops the same way. --expect-plan <hash> binds explicitly. concepts.md has the rules.
Status. zap status tells you what the last run saw, offline:
$ zap status
target local (pkg: none, service: none)
last run on local: zap apply --wetrun, after its changes ran, at 2026-10-11T21:09:26Z — offline; `zap plan` reads the target live
a 0 exit means every declared resource read converged in the last run and its config is unchanged since — not that the target still is
converged notes directory
converged hello fileAgain. Preview once more:
$ zap apply
apply — target local (dry-run)
user: ops
home: /home/ops
target: {"local":true}
(no changes — converged)
plan_hash: 2d374e262633aebd2273acc69efc3122cff2bdbb6dc36f98d7d4bbc6cde5cb44Now edit hello.txt by hand, add a line, and run zap plan --diff: the file
reads [~] hello with ~ content: differs → inline (15 bytes). That is drift,
and zap apply --wetrun would put the declared content back.
Add a package
Declare the package manager on the target and a package:
zap.target({local: true, package_manager: 'apt'});
zap.pkg({id: 'ripgrep', from: {apt: 'ripgrep'}});zap apply now plans two changes: cache-update-apt, the index refresh Zap
puts in front of an install, and the apt-get install -y ripgrep itself, both
run under sudo on the wetrun.
Packages are detected by existence: Zap asks dpkg-query whether ripgrep is
installed, not which version. An entry written name=version pins a version
instead (package names).
Add types
For completion in your editor, import the builder type (type-only, so the evaluator needs nothing installed):
import type {ZapBuilder} from '@fuzdev/zap/zap.ts';
export default (zap: ZapBuilder) => {
// …
};See install.md for resolving the import.
2. A server over SSH
You need a Debian, Ubuntu, or Alpine host you can reach as a user who has
password-less sudo. On a fresh Debian cloud image the stock user usually
qualifies. On Alpine, as root:
setup-apkrepos -c -f
apk update && apk add sudo
adduser ops # skip if the account exists
adduser ops wheel
echo '%wheel ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/wheel
chmod 0440 /etc/sudoers.d/wheelInstall your key and check that plain ssh works silently:
ssh-copy-id ops@<host>
ssh ops@<host> trueThe config. It lives in a project of its own, with a package.json as
before. The host and user are references: they come from your
environment when Zap runs, never from the config file, so the config can be
committed and reused. The user's variable is USER_NAME and not USER on
purpose: your shell already sets USER, so a run that forgot the env file would
connect as your local login name where an unset USER_NAME fails.
// zap.ts
export default (zap) => {
zap.target({
host: zap.env('HOST'),
user: zap.env('USER_NAME'),
home: '/home/ops', // that user's home on the host
package_manager: 'apt', // 'apk' on Alpine
service_manager: 'systemd', // 'openrc' on Alpine
});
zap.pkg({id: 'chrony', from: {apt: 'chrony', apk: 'chrony'}});
zap.service({
id: 'chronyd',
service: 'chrony', // 'chronyd' on Alpine
enabled: true,
running: true,
depends_on: ['chrony'],
});
zap.file({
id: 'probe',
path: '/etc/zap-probe',
content: {type: 'inline', content: 'managed by zap\n'},
mode: '644',
});
};The env file. Put the variables in a file Zap loads with --env-file. Keep
it out of version control; commit a .example instead.
# zap.env
HOST=203.0.113.10
USER_NAME=opsDrive it.
zap lock --env-file zap.env # pin the evaluator
zap detect --env-file zap.env # what the host looks like now
zap apply --env-file zap.env # the plan
zap apply --env-file zap.env --wetrun # converge
zap apply --env-file zap.env # idempotency: (no changes — converged)The dry run shows the apt-get update and the apt-get install, the systemctl enable and start, each with the exact command, and the file it
will write. The wetrun reports 4 ok: the three resources plus the index
refresh.
Edit the probe file on the host, or systemctl stop chrony, and zap plan shows the drift. That loop, plan then wetrun, is all of Zap.
What to commit
| File | Commit? |
|---|---|
zap.ts | yes |
zap.lock | yes, it records the fgro build and any fetched bytes you approved |
zap.env and friends | no; commit a .example |
~/.zap/ | no, it's your machine's cache and last-run reports |
Where to go next
- A real server. The recipes carry the standard pieces: SSH hardening, a hardened systemd or OpenRC service, an nginx site with a CSP, a firewall, certbot, fail2ban, PostgreSQL. configs.md shows how to compose them.
- Shipping an app.
zap deployreleases a build to the host you just converged, with a health gate and rollback: deploy.md. - A host that locks itself down. When SSH hardening changes the user Zap
connects as, declare both users and run the first apply with
--bootstrap: targets.md. - What every command and exit code means. cli.md.
Concepts #
The ideas behind every zap command. Read this once; the other docs assume it.
The loop
describe → detect → plan → apply → observeYou describe a machine in zap.ts: these packages, this file with this
content, this user with these keys, this service running. Zap detects what
the machine looks like now, plans the difference, applies only that
difference when you say so, and observes the result by reading the machine
again. Every command is a slice of that loop.
A task fits Zap when it can be put as: I want the machine to look like X. What does it look like now? What needs to change? Do it. Tell me what happened.
Convergence
A resource is one typed piece of desired state with a stable id. Most
resources are convergent: Zap reads their current state, compares it to the
declaration, and changes only what differs. Re-running is always safe. A
converged resource contributes nothing to the plan.
How much of the live state a declaration claims depends on the field:
| A field that is | Means | For example |
|---|---|---|
| authoritative | made exactly the declared value; anything added by hand is removed | a user's authorized_keys, a file's content |
| additive | each declared entry must be present; an entry added by hand is neither detected nor removed | a firewall's allow rules, a database's pg_hba rules, a user's groups |
| unset | unmanaged: Zap neither sets it nor reverts it, and a rewrite keeps it | a file with no mode |
One resource kind is imperative: a pipeline runs every time, as does zap deploy, which produces a new release every run. You make their steps safe
to repeat.
Drift is the gap between the live machine and the declaration: a file edited on the host, a service stopped, a firewall's default policy flipped at the console. Zap doesn't track drift; it finds it by reading the machine on every run, and the plan is the repair.
What Zap doesn't do: it never removes something you stop declaring. Delete a package resource from the config and the package stays installed. zap status lists such resources as orphaned, as a warning. To remove something, declare
the removal (a script whose check confirms it's gone).
Detection
For each resource, detection answers one question, does the desired state already exist?, with one of four verdicts:
| Verdict | Meaning | The planner |
|---|---|---|
converged | the desired state is present | skips it |
drifted | present but wrong; carries what was observed | plans an update |
absent | not present | plans a create |
undetermined | couldn't tell, with a reason | holds it out of the plan |
When Zap can't read a resource (a probe failed, a remote path's home isn't
declared, a tracked pin has no lock entry), it doesn't guess: the resource is
held, the command leads with an UNDETERMINED banner naming it and why, and the
exit code is 1 (deploy's preflight refuses with 2), never 0. Nothing carries over
between passes; the next run reads it again.
The hold spreads to what can't run without the held resource: a change that depends on it (directly, or through resources that are converged) and a change that would restart or reload it are held behind it, and the banner lists them under it. detection.md
A few kinds on the bring-up path read a failed probe as absent instead,
because on a fresh host the probe fails for the reason the config exists
(ufw status fails before the same run installs ufw). Which kinds, and how database splits the two cases: detection.md.
Depth follows idempotency. If re-running a change is a true no-op, shallow
detection is fine: a package is checked for existence (or, when its name pins a version, that
version), because installing it again does nothing. If re-running has side effects, detection goes deeper: a file is compared by digest, mode, and owner; a user's keys and sudoers by
content; a firewall rule by rule. A script's depth is yours: its check command is the whole idempotency contract.
Everything is read live, every time. Zap keeps no detection cache. A stored
answer is only sound when nothing else could have changed the machine, and Zap
never assumes it is the only writer. The security controls make the point: firewall, user, and database guard the host's access surface and are
exactly what gets changed by hand.
Dry run and wetrun
No command changes the target unless you pass --wetrun. zap apply shows what
would change and exits 2 when something would; zap apply --wetrun changes it.
Most tools make the dry run the option. Zap makes it the default, and gives the
flag that acts an unusual name so it isn't typed by accident or appended by a
script's --yes habit. The default is safe with no terminal and no prompt,
which is what makes Zap safe to hand to a script or an AI assistant.
The plan
A plan is the changeset for one run: per resource, the action (create or update), the steps, the desired state (its references kept as written), and
the dependency order. It is content-addressed: plan_hash is the blake3 of the plan's
canonical JSON and of the text Zap's own steps execute, printed at the end of
every plan and apply.
Two things with different stability go into it. The declaration is a function
of your config and the files it reads: the same zap.ts over the same files
evaluates to the same desired state, because the config runs in a sandbox with
no network and no environment, with the clock and randomness closed and the time
zone and locale fixed (configs.md). The changeset also depends on what detection found. So the hash addresses "what
this run decided to do against this target."
The plan is for one machine. Its header names the target it resolves to; a
local plan also names the user Zap runs as and the HOME a ~ resolves to:
$ zap plan
plan — target local (2 change(s): +2 ~0)
user: ops
home: /home/ops
target: {"local":true}
[+] notes (directory)
path: ~/notes
mode: 755
[+] hello (file)
path: ~/notes/hello.txt
content: inline (15 bytes, sha512 0c7463f032f1dc98…)
plan_hash: 5bb79cc9141942676c784abba55826f12be51b3feb86f5f0cf77666545674d61A remote plan prints the connecting user in a user: line, and the host in a host: line when the declared host is a reference. All of it is in the hash, so
one config under two env files is two plans, and so is the same host with and
without --bootstrap. A local plan's hash covers the user Zap runs as and its HOME, so a review made as you binds no local wetrun under sudo, which runs
as root, or under another HOME.
The plan is for one environment too. A reference in a resource stays written as
its reference ({env:NAME}), and the plan records beside it what each reference
its changes use resolves to, printed in a references: block under the target:
the value of a plain one, and for a secret only that it is
one. So plan_hash covers those values: the same config under another value of APP_DIR is another plan. A secret reference's value is never in the plan or its hash,
so rotating one changes neither.
A wetrun executes only the plan you reviewed
A wetrun doesn't take a saved plan as input; it detects and plans again, because
the machine may have moved. What it guarantees instead is that the plan it
executes is the plan you saw. Before it changes anything, it compares its fresh plan_hash against the plan you reviewed:
--expect-plan <hash>names the reviewed plan explicitly, and the wetrun executes only if the fresh plan has exactly that hash;- with no flag, the reviewed plan is the one in this host's last-run report, when the pass that wrote it was a preview:
zap plan, a dryzap apply, or a wetrun that refused. Its recordedplan_hashmust equal the fresh one.
Otherwise the wetrun refuses. It changes nothing, renders the plan it would
have run under a REFUSED banner that says why (no preview yet, the last run
wasn't a preview, or the plan changed since the preview), records that render as
the new preview, and exits 2. The everyday flow is zap apply, read
it, zap apply --wetrun. A wetrun fired cold becomes the preview, and the next
one executes if nothing moved. A preview covers one wetrun: one that executes,
whether its changes succeed or fail, spends it, and of two started on one
preview only one goes on. An agent given --expect-plan with the hash it
showed you can execute only that plan: the same changes, on the same machine,
as the same user, with the same values for every reference that isn't a secret.
An empty plan needs no review.
On an interactive terminal, a wetrun whose plan has no review asks for the
first eight characters of its plan_hash instead of refusing
(cli.md).
A plan can only be reviewed if it names its machine. A plan whose host or
connecting user resolves through a reference Zap masks has no plan_hash: zap plan and a dry zap apply show it under an UNBINDABLE note, and zap apply --wetrun is an error before anything runs, even when nothing would
change. The ways out are in env.md.
zap deploy has its own plan, hash, and recorded preview, and its wetrun
follows the same rule against them
(deploy.md).
Apply asserts convergence
A wetrun doesn't stop at command success. After the changes run, it reads every
resource again. Any resource that still doesn't read converged is reported as RESIDUAL and the run exits 1: a script whose check still fails after its run, a mutation that read back wrong. A change that failed or was skipped is
the run's failure already, a held resource its undetermined, and a pipeline never reads converged (it always runs), so none of them is residual.
"Applied: N ok" alone would be a claim about commands; the re-detection makes it
a claim about the machine. If the re-detection itself fails, the run says so and
exits 1 too, because "converged" was not established.
The last-run report
Every detection pass writes a last-run report under ~/.zap, one per config
and host, latest only (none for a host that resolves through a masked reference,
since it would stand for every machine the reference names): when it ran, which command, the host, each resource's
verdict and a hash of its declaration, the names of the references it couldn't resolve,
the batches it ran, and the plan_hash when the pass planned. Nothing the
target returned is in it, and no secret's value reaches any hash
(env.md).
Detection never reads it. zap status renders it offline, marking each resource
whose config changed since the run, and it holds the preview a wetrun binds to. Exit
codes are about the last run, not the live machine; the live answer is always zap plan.
Triggers
Service detection reads running and enabled, not whether the service's config
is fresh. Triggers close that gap: a file names the services it triggers, or
a service names the resources it restart_on / reload_on. When a source
changes in the current plan, the service is promoted from skip to restart or
reload. A trigger restarts or reloads only a running service: a stopped one is
left stopped and starts on the new state when it starts. A trigger on a service
declared running: false is a config error.
A trigger fires only on a change in the current plan, so a failed reload would
be lost: next run the file is converged and the service is running, on the old
config. Zap keeps one marker per owed service on the target under /var/lib/zap/pending/, written before the first source changes and cleared
after the service's change completes. A failed reload stays owed and re-fires on
the next plan. At least once, never zero.
A held service, whether detection can't read it or it is held behind another, holds every source whose change would trigger it: run alone, the source would owe a restart that no change in the plan performs. Both wait for a plan that can run the service.
The lock file
A moving reference makes intent ambiguous in time: a tracked remote file's URL
or a build's source archive can serve different bytes tomorrow. zap.lock,
next to zap.ts and committed with it, pins each tracked reference to the
blake3 of its resolved bytes. Detection compares the target against that pin, and
only an explicit act moves it: zap update (advance one or all), zap lock (rebuild), or a wetrun recording a pin that was missing. A pin that isn't current is withheld from the read-only commands, so
the resource holds rather than being compared against stale bytes. Full detail: lock.md.
Targets and privilege
A config has one target: your own machine (local: true) or a host over SSH.
Your authority on a remote host is SSH: Zap connects as the user you declare,
with your keys, and escalates with the host's sudo. Commands that are
privileged by nature (apt-get, systemctl, ufw) carry sudo. On a local
target file operations try natively first and escalate only when denied; over
SSH they run under sudo. There is no agent,
no server, and no shared secret. See targets.md.
Secrets and references
A config never reads the environment. Anything that varies per machine or is secret is written as a reference, which the plan's steps keep as written and Zap resolves when it acts; the plan records what each plain one resolves to, so a review covers it. A secret's value is masked out of every output, event, and report. A secret written literally into a config is rejected when Zap can tell (its value is referenced and set in the environment). See env.md.
Deploy
zap deploy is the one imperative command: it ships a versioned release to a
remote host as releases/<version> plus a current symlink, restarts the
service, polls health, and rolls back if the health gate fails. Before shipping,
its preflight runs the convergence pass and refuses to ship onto a host that has
drifted from its config. The same tool that built the machine ships the app.
See deploy.md.
Writing configs #
How a zap.ts is shaped, what it may and may not do, and the patterns that keep
one readable. The per-resource fields are in resources.md.
The shape
A config is a module whose default export is a function. It receives a builder, declares one target plus any number of resources, and returns nothing:
import type {ZapBuilder} from '@fuzdev/zap/zap.ts';
export default (zap: ZapBuilder) => {
zap.target({host: zap.env('HOST'), user: 'ops', home: '/home/ops', package_manager: 'apt', service_manager: 'systemd'});
zap.pkg({id: 'nginx', from: {apt: 'nginx'}});
zap.file({
id: 'site',
path: '/etc/nginx/sites-available/example',
content: {type: 'inline', content: site_config},
validate: 'sudo nginx -t',
triggers: ['nginx-service'],
depends_on: ['nginx'],
});
zap.service({id: 'nginx-service', service: 'nginx', enabled: true, running: true, depends_on: ['nginx']});
};The builder has one method per resource kind (pkg, apt_repo, script, installer, user, directory, file, link, service, database, swap, firewall, verify, pipeline, build), plus target, deploy, mask, unmask, and env (which returns a reference). Every other call returns the
builder, so chaining works, and every resource
takes these shared fields:
| Field | Required | Meaning |
|---|---|---|
id | yes | a stable name, unique in the config: a letter, a digit, or _, then any of those and . + : @ -. Output, events, triggers, and depends_on all refer to it. Renaming an id makes Zap see a new resource. |
name | no | a label for whoever reads the config. A plan prints it under the change's header; everything else (apply's progress, events, triggers) names a resource by its id. It is carried in a change, so changing it changes the plan_hash of a plan that changes the resource, but never the lock entry's config_hash. |
depends_on | no | ids that must apply before this one |
The import is type-only. The evaluator strips it, so a config with no recipe
imports needs nothing installed. Without it, write (zap) => and lose completion.
The function may be async: Zap waits for it, so a declaration after an await counts. Once it returns, the builder closes, and a call that arrives
later (from a promise the config didn't await) fails the evaluation, even when
the config catches the error, so a forgotten await can't drop a resource
silently.
Plain TypeScript is the templating language
There is no DSL for loops, conditionals, or includes; the language has them.
const PACKAGES = ['build-essential', 'pkg-config', 'libssl-dev'];
for (const pkg of PACKAGES) {
zap.pkg({id: pkg, from: {apt: pkg}});
}
const USER = 'app';
const HOME = `/home/${USER}`;
zap.user({id: 'app-user', username: USER, ssh_keys: {copy_from: 'root'}, sudo: false});
zap.directory({id: 'app-dir', path: `${HOME}/app`, owner: USER, group: USER, depends_on: ['app-user']});Keep every value two resources must agree on (a user name, a config directory, a port) in one constant. Nothing fails when two copies drift apart.
Split a large config into local modules and import them by relative path from the project. The shipped recipes are the same idea packaged: pure functions that return a string or a spreadable payload.
import {SSHD_VALIDATE, ssh_hardening, ssh_hardening_verify} from '@fuzdev/zap/recipe_ssh.ts';
import {debian_ssh_service} from '@fuzdev/zap/recipe_debian_ssh.ts';
const HARDENING = {user: USER};
zap.file({
id: 'sshd-hardening',
path: '/etc/ssh/sshd_config.d/00-hardened.conf',
content: {type: 'inline', content: ssh_hardening(HARDENING)},
mode: '644',
validate: SSHD_VALIDATE,
});
zap.service({id: 'sshd', ...debian_ssh_service({reload_on: ['sshd-hardening']}), depends_on: ['sshd-hardening']});
zap.verify({id: 'sshd-hardened', ...ssh_hardening_verify(HARDENING), depends_on: ['sshd']});One target per config
A config declares one target. For several hosts, write one config per host (or
one module per role, imported by each host's config) and run zap once per host.
A zap.env('HOST') reference plus one env file per host is the usual shape.
A second zap.target call, or a second zap.deploy, fails the evaluation.
References
A config never reads the environment. Anything that varies per machine or is
secret is a reference, zap.env('NAME'), left inert in the declaration and
resolved by Zap when it acts, from your environment or an --env-file:
zap.target({host: zap.env('DEPLOY_HOST'), user: zap.env('DEPLOY_USER')});
zap.file({id: 'env', path: '/srv/app/shared/.env', content: {type: 'template', template: 'DATABASE_URL={{url}}\n', vars: {url: zap.env('DATABASE_URL')}}});Not every field takes a reference, a wetrun refuses one that is unset or empty, and one Zap reads as a secret is masked out of every output; the rules are in env.md.
~ and $HOME in a path are different: they resolve against the target's home (declared home for a remote target, your own locally), never the
operator's. A remote path that uses them with no home declared is
undetermined.
mask and unmask
Zap treats a reference as a secret by where it is used (an auth credential),
by its name (API_TOKEN, DB_PASS), or by the form of its value (a URL with a
password, a private key); the rule is in env.md. Say so
where it reads one wrongly:
zap.mask('LICENSE_CODE'); // a secret the rule doesn't recognize
zap.unmask('SSH_KEY_COMMENT'); // not a secret despite the nameBoth take variable names, not references: zap.mask(zap.env('X')) fails the
evaluation, and so does a name given to both.
Order
Resources apply in dependency order: depends_on edges first, and among
resources with no edge between them, alphabetically by id, not in
declaration order. Declaration order is not a dependency. When one resource must
precede another, say so with depends_on; the structural gate rejects a
dangling id or a cycle. A trigger is an edge too: a service runs after each
resource it restart_on / reload_on and each file that triggers it, and a
cycle through those edges is rejected the same way.
Detection for the whole run happens before any change executes. So a probe can't see a tool the same run installs; the detectors on the bring-up path read that failure as "not set up yet" rather than holding (detection.md), and the run both installs and configures. concepts.md has the rule.
An edge is also how a hold travels: a resource detection can't read is held, and every change that depends on it is held behind it rather than run ahead of it (detection.md).
Triggers
A file lists the services it triggers; a service lists the ids it restart_on or reload_on. Either direction works; pick one per pair. When the
source changes in a plan, the service is promoted to a restart or a reload. A
reload the run couldn't complete is remembered on the host and re-fired next
plan (concepts.md).
zap.file({id: 'unit', path: '/etc/systemd/system/app.service', content: {…}, triggers: ['app']});
zap.service({id: 'app', service: 'app', enabled: true, restart_on: ['env-file']});The sandbox
Your config runs in fgro eval, Gro's runtime, with no network, no writes, and
no environment: it can compute and import, and that is all. These rules shape
how you write one:
- It lives in a project: the nearest directory at or above it whose
package.jsonholds more than"type"and"sideEffects". The project can't be your home directory or a directory holding it. - Its imports are static, from that project and the packages it links, and
must all resolve:
import()is refused, and so arenode:built-ins. - Resolution is strict ESM: a relative import names its extension, and a
bare import goes through the package's
exports(never itsmain) under the conditionsgro,import, anddefault. Nodeno.jsonor import map is read.
Zap refuses a config that breaks one, exit 2, saying what to change. The walls in full: security.md.
The config's only output is a JSON declaration, which every command validates
against a strict schema: an unknown field, a wrong enum value, a value of the
wrong JSON type (an array where an object goes, a number where a name goes), or
a contradictory combination (a local target with a host, sudo: true on a
user) fails with a message that names it.
What a config declares is plain data: strings, finite numbers, booleans, arrays,
and plain objects. A NaN, a function, a Map, a Date, a class instance
inside a declaration, an undefined array item, or a value that holds itself
fails the evaluation with the field's name, where JSON.stringify would have dropped it or written something else. A field is
left out by leaving it out, or by giving it undefined (so mode: opts.mode forwards an option that may not be set). A null is an error that names the
field, since a null where a mode, a sudo, or a blake3 pin goes would
otherwise read as "not managed". The one place a null is data is inside a
template variable's value.
Zap also closes what a config could read by mistake and get a different answer
from on another run or another machine: the clock, randomness, facts of the
machine, and timers. Each throws, naming itself and what to write instead, or
isn't there. The time zone and locale are fixed rather than closed: every
evaluation runs in UTC, and its default locale is en-US. A config reads no file
but the modules it imports, so the same files declare the same thing on every
machine.
Consequences worth knowing:
- The clock and randomness throw:
Date.now(),new Date(),performance.now(),Temporal.Now.instant(),Math.random(). There is nocrypto, nosetTimeout, and noBloborFile. Write the value as a literal, or reference it. - Local time is UTC:
new Date(2026, 0, 1)andgetHours()read the same asDate.UTC(2026, 0, 1)andgetUTCHours()on every machine. - Comparing and formatting with no locale given use
en-US:(1234.5).toLocaleString()is1,234.5. - There is no
process, noDeno, and no environment API. Usezap.env('NAME'). A guarded read such asglobalThis.process?.env?.HOMEisundefined, not an error, so a config that falls back on it always takes the fallback. - What a config prints is not part of the declaration:
consolewrites to stderr, which Zap shows when the evaluation fails.console.time,console.timeLogandconsole.timeEndprint nothing. - The evaluation is jitless, so
WebAssemblyis absent; separately, code generation from strings is off, soeval()andnew Function()throw. WeakRefandFinalizationRegistryare inert: a weak reference never clears, and a cleanup callback never runs.fetchand remoteimportfail. Vendor what you need or import it from the project.- A
script'srunis shell that executes on the target at apply time. The sandbox bounds the config, not the commands it declares. Review them.
What the gates reject
Every command that reads a config runs two machine-free gates before touching anything, and zap config validate runs only them:
- Secrets. A literal that matches a resolvable secret is rejected; reference
it with
zap.env('NAME'). - Structure. Each of these is rejected:
- a NUL byte in a string a shell or path would see;
- a duplicate id, or an id that isn't a name;
- a blank string where a name, a path, a URL, or a command goes, and a line
break or other control character in a field that is one line (a command, a
validateincluded, a file's content and template variables, anenvvalue, and averifyassertion'sexpectmay span lines); - a path that must be absolute and isn't, and a path, link target, host, or
user that begins with
-; - a remote target with no
host; - two resources that manage one thing: the same path, account, service, or
database, or a second
firewall(resources.md); - a template whose placeholders and variables don't match (resources.md);
- a URL Zap or the target would fetch that isn't
https://(resources.md); - a
databasename, role, orauth.hostsentry thatpg_hba.confwould read as something else; - a
depends_on,triggers,restart_on, orreload_onnaming an id that doesn't exist, atriggersnaming a resource that isn't a service, or a dependency cycle, trigger edges included; - a
userwhosessh_keys.keysholds no key; pipelinefeatures the executor doesn't run (env, amax_concurrencyabove 1, a step'scwd,env, ordepends_on);- a named firewall profile under nftables, or an nftables firewall with no
enabled
nftablesservice in the same config; - a
service, afilewithtriggers, ordeploy.serviceon a target with noservice_manager; - a
validatecommand that is blank or onremotecontent; - a
scriptcwdor adeploy.paththat isn't an absolute path; - a
deploy.sharedentry that isn't a plain relative name, or adeploy.artifactsentry beginning with-or@(deploy.md); - an
envname beginning with__zap_; - a
deploy.releasesbelow 2; - a blank
deploy.health.domain.
Two kinds of mistake fail earlier, when Zap reads the config, so the gates never
see them: a value of the wrong form (a package or service name that isn't one
word, a mode above 7777, a port or a count of 0), and a reference in a field
that takes none (env.md). An object key $env or $text anywhere in a declaration is reserved and fails the evaluation.
Run zap config validate in CI and before committing; it needs no target, no
network, and no secrets.
Checklist for a new config
zap config validatepasses.zap plan --env-file …reads every resource (noUNDETERMINED, noUNVERIFIABLE).- Every
scripthas acheckprecise enough that a converged host plans no change. A check that always fails makes the script run on every apply and readsRESIDUALafter it. - Services that depend on files declare the trigger, and the files that must
be valid before a reload declare
validate. - A second
zap applyafter the wetrun prints(no changes — converged). zap.tsandzap.lockare committed and the env files aren't (getting-started.md).
Deploy #
zap deploy ships a versioned release of your application to a remote host over
SSH and swaps it in atomically, with a health gate and automatic rollback. The
host is one your config already converges with zap apply: the same tool that
built the machine ships the app, and the deploy refuses to ship onto a host that
has drifted from its config.
Deploy is the one imperative command. Each run produces a new release, so there is nothing to converge to. It still plans before it acts: a dry run prints every step with the command, script, or URL behind it, and a wetrun executes only the plan a dry run showed.
Configuration
export default (zap) => {
zap.target({
host: zap.env('DEPLOY_HOST'),
user: zap.env('DEPLOY_USER'),
package_manager: 'apt',
service_manager: 'systemd',
home: '/home/app',
});
zap.deploy({
build: 'gro build',
artifacts: ['build/', 'dist_artifacts/'],
path: '/home/app/app',
releases: 5,
service: 'app',
shared: ['.env.production'],
prepare: ['deno cache server.js'],
health: {domain: zap.env('DEPLOY_DOMAIN'), path: '/health', attempts: 15, interval_ms: 2000},
});
};| Field | Required | Meaning |
|---|---|---|
build | no | command run locally before packaging; omit it, or pass --skip-build, and the plan has no build step |
artifacts | yes | files and directories, relative to the directory you run zap from, packaged into the release. An entry can't begin with - (tar would read it as an option; write ./-name) or with @ (which some tars read as an archive to include) |
path | yes | the remote app root: holds releases/, shared/, and the current symlink. An absolute path, written out (no reference, no ~); it must exist and be writable by the connecting user, which zap apply usually arranges |
releases | no | how many releases to keep, the live one included (default 5). At least 2: the release just deployed and the one it replaced are always kept, since a failed deploy rolls back to the second |
service | no | the service restarted after the swap; requires target.service_manager; takes no reference |
shared | no | files under <path>/shared/ symlinked into each release root. Each entry is a relative name of a file: no .. component, no control character, not ending in / or in a . component (config/.); anything else is rejected by the structural gate. Each must exist (zap apply usually writes it): the deploy fails before extracting otherwise |
prepare | no | commands run on the target in the new release, as the connecting user, before the swap, each in a shell of its own; the release is still under a hidden staging name then, so a command shouldn't record its absolute path |
health | no | the post-restart health gate, below |
health
| Field | Required | Meaning |
|---|---|---|
domain | yes | the host polled over HTTPS; usually a zap.env('DEPLOY_DOMAIN') reference |
path | yes | the request path, beginning with / (/health, or /ready to make it the gate); takes no reference |
attempts | no | polls before the deploy fails (default 15), at least 1 |
interval_ms | no | delay between polls in milliseconds (default 2000) |
Omit the block to deploy with no health gate and no readiness check; there is no flag to disarm a declared one.
Commands
zap deploy # dry run: the whole plan and its plan_hash, recorded as the preview
zap deploy --wetrun # ship the previewed plan: build, upload, swap, restart, health-check
zap deploy --wetrun --expect-plan <hash> # ship only the plan with this hash
zap deploy --wetrun --skip-build # no build step: ship the local artifacts as they are
zap deploy --wetrun --skip-preflight # ship without the drift preflight
zap deploy --wetrun --force # ship an unchanged version, or a dirty tree
zap deploy --wetrun --json # stream step events as JSON linesA wetrun binds to a preview made with the same plan-changing flags
(below). --env-file <path> loads the
operator env first, as for every command. Flags, exit codes, and what a dry run
reads: cli.md.
What it needs. On your machine: git, tar with gzip, and ssh, with zap run from
inside the application's git checkout. On the target: what a file write needs
(targets.md), plus tar, id, and flock.
First deploy
A first deploy onto a fresh host is a chicken-and-egg: the binary lands via deploy, but deploy's preflight requires a converged apply, and a service declared running: true can't be running before there is anything to run.
Resolve it in the config: declare the service enabled and leave running unset.
zap.service({
id: 'app-service',
service: 'app',
enabled: true,
// no `running`: zap manages enablement, not runtime state, so the service
// doesn't drift before a binary exists; the deploy's restart starts it
restart_on: ['app-unit'],
});Then the bring-up is two reviewed runs: zap apply, then zap apply --wetrun,
provisions the host (firewall included, so deploying first would bail), and zap deploy, then zap deploy --wetrun, ships the first release, whose
restart step starts the service. Preview the deploy after the apply has run:
the dry run connects as the user the apply sets up, and a pin the apply writes
to zap.lock is part of the deploy plan.
The trade is explicit: a config that leaves running unset tells Zap never to
assert that the app is up, so zap apply on a host whose daemon crashed and
stayed down reports converged. Declaring running: true buys that assertion
back at the cost of one deploy --wetrun --skip-preflight on the very first
release (previewed with deploy --skip-preflight, since the flag is part of
the plan), and --skip-preflight disables the whole drift gate for that run.
When SSH hardening changes the user Zap connects as, add --bootstrap to the
first apply: targets.md.
Binding a wetrun to a reviewed plan
A dry run prints the whole plan and ends with its hash:
deploy — target {env:DEPLOY_HOST} (dry-run)
version: 3f2a9c1e7b04 (previous: 8d41e0a6c2f9)
host: app.example.com
target: {"local":false,"host":{"$env":"DEPLOY_HOST"},"user":{"$env":"DEPLOY_USER"},"home":"/home/app","package_manager":"apt","service_manager":"systemd"}
references:
DEPLOY_DOMAIN = app.example.com
tree: clean
preflight: no apply-managed resources declared — nothing to check
- Build application (build)
local$ gro build
- Create tarball (tarball)
local$ tar -czf "$ZAP_DEPLOY_ARCHIVE" -- 'build/' 'dist_artifacts/'
writes $ZAP_DEPLOY_ARCHIVE: a file in a private directory of this run, removed when it ends
- Upload tarball (upload)
the archive → target:/home/app/app/.zap-deploy-app-3f2a9c1e7b04.tar.gz (as the connecting user, mode 600, checked against its SHA-512)
- Extract and swap release (extract_swap)
target script (its stdin: the archive's SHA-512):
set -e
zap_archive='/home/app/app/.zap-deploy-app-3f2a9c1e7b04.tar.gz'
…
- Restart app (restart)
target$ sudo systemctl daemon-reload && sudo systemctl restart app
- Health check (health)
GET https://{env:DEPLOY_DOMAIN}/health — healthy on 2xx; up to 15 attempt(s), 2000 ms apart
polls https://app.example.com/health
- Readiness check (readiness)
GET https://{env:DEPLOY_DOMAIN}/ready — ready on 2xx, unserved on 404; up to 15 attempt(s), 2000 ms apart
polls https://app.example.com/ready
(dry-run — re-run with --wetrun to deploy)
plan_hash: 1f98612fcbfacb69b9d31236457d549e00714e4ad0fb0a36e225c3fe6a5f4312A wetrun executes only a plan you were shown. It computes its plan again, and that plan's hash must equal the reviewed one:
--expect-plan <hash>names the reviewed plan explicitly, and binds alone;- with no flag, the reviewed plan is the deploy preview recorded for this
config and host: the last
zap deploydry run, or a wetrun that refused.
Otherwise the wetrun refuses before the drift preflight and any step,
prints the plan it would have run under a REFUSED banner that says why (no
preview yet, the plan changed since the preview, or the hash doesn't match),
records that render as the new preview, and exits 2. Nothing is built or
uploaded. The everyday flow is zap deploy, read it, zap deploy --wetrun with the same flags. A wetrun fired cold becomes the preview, and the next one
executes if nothing moved. There is no flag that skips the review, and a dry
run rejects --expect-plan.
On an interactive terminal (standard input and standard error both terminals),
a wetrun with no review and no --expect-plan shows the plan instead, under an UNREVIEWED banner with its plan_hash, and ends with the question:
UNREVIEWED — this plan is not the one that was reviewed.
no deploy plan has been previewed for this host.
plan_hash: 1f98612fcbfacb69b9d31236457d549e00714e4ad0fb0a36e225c3fe6a5f4312
Deploy this plan? Type 1f98612f, the start of its plan_hash, to confirm:Typing those eight characters back is the review: the wetrun goes on with
exactly the plan shown, to the drift preflight, the withdrawal of any recorded
preview, and the steps, as a bound wetrun does. Any other answer is the refusal
above, under a DECLINED banner. The prompt follows zap apply's rules: no
flag answers it, --json never asks, and a script stays non-interactive by
previewing first, passing --expect-plan, or running with standard input that
isn't a terminal (cli.md).
One recorded preview covers one execution: a wetrun that passes the review and
the preflight withdraws it before its first step, so a deploy that fails needs a
new preview (or --expect-plan) before it runs again, and of two wetruns
started on one preview only one goes on. An --expect-plan hash is not
single-use. A preflight bail or an already-deployed exit leaves the preview in
place. A preview doesn't expire.
The preview is a small file under ~/.zap (lock.md), separate from the last-run report that zap apply binds to: a zap plan doesn't disturb a deploy preview, and a
deploy dry run doesn't disturb a convergence one. It holds the hash, not the
plan. A deploy wetrun's drift preflight does replace the convergence record: it
records its pass as the last-run report, which is no preview, so a zap apply previewed before it needs a new preview before its wetrun. With no state
directory nothing is recorded, the run says so, and only --expect-plan binds.
Everything the hash covers is printed but two of Zap's own scripts: the one the
upload installs the archive with, and the swap the auto-rollback runs. So a plan
reviewed under one zap build doesn't bind one whose scripts differ. It covers:
- the version, the tree state, and the previous release on the target;
- the declared target, the host it resolves to, and the connecting user (in the uploaded archive's name);
- who is deploying (
user@hostof the operator machine, recorded indeploy_info.json); - the preflight's state: skipped, nothing declared, or which apply-managed
resources it checks, as a count and a digest of their declarations and the
current pins
zap.lockholds for them; - every step: the build and tarball commands, the upload paths and the script
that installs the archive, the remote script with its
sharedlinks andpreparecommands, the restart command, the health and readiness URLs as they resolve, and the swap the auto-rollback would run; - the value of each reference the
deployblock uses (its health domain), printed in itsreferences:block (a secret's value excepted: the plan records only that it is one).
So a plan reviewed for one host, tree, flag set, or operator does not bind a
wetrun for another: a new commit, a deploy or rollback by someone else in
between, an edit to any declared resource, a changed pin in zap.lock, --force on a dirty tree, --skip-build, --skip-preflight, or --bootstrap each make a different plan. The hash is printed, and the preview recorded, only
when the steps are shown: an already-deployed dry run does neither, so a
redeploy binds to the plan a --force dry run shows.
It does not cover:
- the artifact bytes. The version names the commit, and the plan says whether the tree was dirty, but what the build produces is not hashed. The review covers the script that verifies the archive it was sent, not the archive.
- the values a resource's references resolve to. The digest takes
each declaration as written, so it is the same in every environment; the
environment reaches the hash only through the host, the connecting user, and
the health domain. The same holds for the bytes of a local
sourcefile.
A masked host, connecting user, or health domain can't be deployed to. No
review can bind such a plan: a wetrun is an error that names the reference
before anything runs, and a dry run shows the plan under an UNBINDABLE note,
with no plan_hash and no recorded preview. The ways out: env.md.
Text from the config, the environment, and the target is printed with control
characters written out as <U+XXXX>, so a value can't hide or forge a line of
the preview (security.md).
Preflight
Before deploy touches the network, the machine-free gates run (secrets,
structure, and, for a deploy config, a present health.domain), and a wetrun
rejects any unset reference and a host, connecting user, or health domain behind
a masked reference. zap.lock is read here too, when the drift preflight will
check. --skip-preflight doesn't skip the gates.
Then a wetrun checks its review and runs
the drift preflight: the convergence pass against the config, before
building or uploading. If apply-managed state (an nginx site, the
env file, the service unit) was edited in the config but never applied, shipping
a new artifact against it would be a silent mismatch; the preflight bails with
exit 2, and a change held behind a resource it couldn't read counts as drift. A pipeline, which detects absent and runs on every apply, is not drift, and
neither is a service that changes only because its trigger fires, planned or
held: it reads converged, no pending restart or reload is recorded for it, and
every source that fires it is itself not drift (a pipeline, or another such
service). A service that also drifted, one owed an action an earlier apply
never completed, or one fired by any other change is drift.
It also bails, with the same code, when any resource is undetermined: a gate
that couldn't check everything doesn't pass by default.
After a drift bail, review the changes with zap apply, converge with zap apply --wetrun, and re-run the deploy: its review still binds unless zap.lock changed.
--skip-preflight skips the drift preflight, and a config that declares no
apply-managed resources has nothing to check. Either way the plan says so on its preflight: line.
The deploy also refuses when the live release already has this version, on a
dry run too (server already at this version, exit 0). --force ships anyway.
A current that names this version but whose release isn't on the host serves
nothing, so it isn't refused: the run plans a redeploy and warns that current names a release that is not on the host.
Dirty trees
A tree with uncommitted changes or untracked files git doesn't ignore deploys
only under --force, as <hash>-dirty-<id>. Without it, a wetrun is refused
before the target is contacted (exit 2), since the release would ship the tree
under the commit's version; a dry run shows the plan
(tree: uncommitted changes or untracked files) with no hash, records nothing,
and says the wetrun is refused, even when the commit is the live release.
The id is 8 lowercase hex of a blake3 over what the tree adds to the commit, so the same tree gets the same id (a dry run and its wetrun agree) and a second dirty tree on one commit is a release of its own:
| In the tree | Counts by |
|---|---|
| tracked and staged changes | git diff HEAD --binary |
| an untracked file git doesn't ignore | its name and contents |
| an untracked symlink | the path it points to, as git records one, whether that is a file, a directory, or nothing |
| an untracked nested repository | its name alone |
| a submodule | its commit and whether its own index shows changes, not what they are |
| a sparse checkout | the files outside its cone, as deletions: the tree reads dirty |
Zap reads the tree against a copy of the index's entries that holds no
timestamps or flags, so every file is compared by its contents: a file the
index marks unchanged (assume-unchanged, skip-worktree) counts like any
other, and so does one whose timestamps the index's cache would take for
unchanged. Each read sets core.fsmonitor=false, core.untrackedCache=false, core.ignoreStat=false, core.fileMode=true (a mode change counts), core.autocrlf=false (line endings are not converted), and core.splitIndex=false, and passes --untracked-files=normal and --ignore-submodules=none, so status.showUntrackedFiles, a submodule's ignore setting, and diff.ignoreSubmodules don't narrow it either. So a
checkout on a filesystem without executable bits, or one checked out with core.autocrlf=true, reads dirty, and deploys only under --force.
Two things still decide what counts as a change. The checkout's attributes
(.gitattributes, .git/info/attributes) are applied: a clean filter, LFS
among them, or an eol or text attribute can make a file that differs from
the commit compare equal to it, and no flag turns them off. A submodule's own
index is read as it is, so an assume-unchanged edit or stale timestamps
inside one can read clean.
A sparse checkout reads dirty and deploys only under --force: the files
outside its cone aren't there, so the tree the build sees is not the commit's.
A tree Zap can't read is an error that says what git said, not a clean tree.
The release mechanism
Once the preflight passes, a wetrun runs these steps in order:
build (local) → tarball → upload → extract + swap → restart → health → readiness- build: run
deploy.buildlocally (gro build,cargo build --release, whatever produces your artifacts). With--skip-build, or when no build command is declared, the plan has no build step. - tarball: package
deploy.artifactsinto a release archive, a file in a private directory of this run on your machine, removed when the run ends. - upload: install the archive into
<path>as the connecting user, mode600, under.zap-deploy-<user>-<version>.tar.gz. It is staged beside that name and checked against its SHA-512 before it is renamed in, so a transfer cut short installs nothing. The archive is held in memory while it uploads. - extract + swap: one remote script, generated deterministically and
printed whole by the dry run, does the release-directory work. It reads the
archive's SHA-512 on its stdin, opens the archive once, requires it to be the
connecting user's own file, writable by no one else, with that digest, and
extracts through the open file, so another account on the host can't swap
the archive between the upload and the extraction. Extracted files belong to
the connecting user, under that user's umask, without set-id bits on files.
The script removes the archive on every exit of its own. Then:
- lock: take the app's deploy lock, a kernel
flockon<path>/.zap-deploy.lock, held until the script exits. The file is never removed and holds no data; when Zap creates it, other users get no permission on it. It is opened read-only, so the connecting user and its group can hold it, the group even under a umask that gives it read alone (022). A deploy that finds the lock held stops at once, before it touchesreleases/, withanother deploy is running for this app(exit 1); it doesn't wait. The kernel releases the lock when the script's process ends, however it ends; a lock held by a hung deploy is cleared by ending that process (fuser <path>/.zap-deploy.locknames it). Thepreparecommands run with the lock's descriptor closed, so a process one of them leaves running doesn't keep the lock once the script ends; - recover: under the lock, repair what an unfinished deploy (killed, or
its connection cut) left in
releases/: a staging directory is removed, and a release it had moved aside is put back under its version when that name is empty, or removed when it is filled. So acurrentthat a killed--forceleft naming nothing resolves on the next deploy (a redeploy of that version needs no--force: Preflight). Each repair is a warning in the run's text report (not in a--jsonstream), and happens even when that deploy then fails; - check: still under the lock, confirm
currentnames the release the plan read as live (by the link's last segment, trailing slashes dropped; nothing at all atcurrent, no link and no file or directory, on a first deploy). A deploy planned before another one swapped stops here, sayingcurrentchanged since this deploy was planned (exit 1), having made nothing of its own; runzap deployagain. So the release a rollback returns to, and the one the prune keeps, is the one live just before this deploy's swap. A plan that couldn't readcurrent(it warns) stops here too when the host has one; - extract into a fresh hidden directory,
<path>/releases/.zap-stage; - link shared: symlink each
deploy.sharedentry from<path>/shared/into the new release (an apply-managed env file, for example); a release that ships a directory at a shared name fails the deploy here; - prepare: run each
deploy.preparecommand in the new release, before the swap, in a shell of its own (sh -ec): anexitorexecin one ends that command, not the script, and a command that fails stops the deploy before the swap. Its working directory is the hidden one, not the release's final path, and its output goes to the script's error stream: a step that fails shows the script's own reason when it stopped itself, or else the stream's last lines, and what a command prints becomes no run warning. A command that reaches the script's own output anyway (through/proc, or a descriptor it was handed) can print a line the script marks, a warning or its reason for stopping, which is then read as the script's: text only, since what the deploy does turns on the exit status and the swap; - place: rename the complete release to
<path>/releases/<version>, where the version is the commit's hash abbreviated to 12 hex digits (git rev-parse --short=12, longer only where 12 would be ambiguous), with-dirty-<id>for a forced dirty tree, above. When that name already exists (an earlier deploy of the same version, or the live release under--force), it is renamed aside first, to.zap-old-<version>, and removed after the swap, so a release never mixes files from two archives. A run that fails before this point leaves no hidden directory and changes no release beyond what it recovered; under--forceof the live version,currentnames nothing for the instant between the two renames, and a run that fails after this point leaves the new contents live under that name, the old ones not kept. A run killed between the two renames leaves the old release aside for the next deploy's recovery to put back; - swap: re-point
<path>/currentat the new release atomically. From here the release is live, and the run goes on to restart and check it whatever the rest of the script does: what follows is housekeeping, and a failure there is a warning in the run's output, not a failed step. A script that fails after saying it swapped is taken as swapped only whencurrent, read again, names the new release; - record: write
<path>/deploy_info.json(version, who, when, previous release); - prune: keep
deploy.releasesreleases: the one just deployed, the one it replaced, then the newest others. Only a directory named the way Zap names a release (a commit hash of 4 to 64 hex digits, alone or with-dirty-<id>) is ever removed; anything else underreleases/is left alone. As a guard, the prune removes nothing unlesscurrentnames a release, and on a target with nosortit removes nothing and warns.
- lock: take the app's deploy lock, a kernel
- restart: restart
deploy.servicethrough the target's service manager (with adaemon-reloadfirst on systemd). - health: poll
https://<domain><path>from the operator machine (the one you runzapon) until it answers 2xx, up toattemptstimes,interval_msapart. Neither poll follows a redirect: a 3xx is not healthy or ready, so a release that redirects to a login page doesn't pass on that page's 200. - readiness: when
health.pathisn't already/ready, poll/readytoo. 2xx proceeds. When the attempts run out, the last HTTP status decides; a connection failure after it doesn't replace it. A 404 means the app doesn't serve/ready: the step is recorded asskipped, warns loudly, and the deploy proceeds on liveness alone. Any other answer fails the deploy and rolls back: a 503 (the convention for a database schema that doesn't match the running code, or an unreachable database), any other status, or no answer at all. Pointhealth.pathat/readyto make that one check the gate; then any non-2xx, 404 included, rolls back.
Auto-rollback
If a step after the swap fails (the restart errors, health never goes green, /ready answers 503), Zap re-points current at the previous release,
restarts, and re-checks health (the health URL, not /ready), recording the
rollback as warnings and step events. The deploy still exits 1: rollback
restores service, it doesn't paper over the failure.
The re-point is a compare-and-swap under the app's deploy lock, taken without
waiting. It moves current back only while current still names this deploy's
release and the previous release is still on the host. Otherwise (another
deploy holds the lock or has replaced the release, someone re-pointed current by hand, current names nothing, or the previous release is gone) the rollback
is skipped with a warning that says why: nothing is re-pointed or restarted, and
the deploy still exits 1.
There is nothing to roll back to on a first deploy, or on a --force redeploy
of the live version: current stays at the failed release, and the run warns
that a manual rollback is needed.
When the restored release's health check fails too, the rollback step is recorded as failed, and the warning says manual intervention is needed.
The previous release is read from the live current symlink at deploy time,
not from a recorded file, so a manual rollback done by hand is seen correctly
on the next deploy. A current whose last segment, trailing slashes dropped,
is no release's name names no previous release, so the deploy refuses before
it builds anything: point current at the live release by hand, or remove it
to deploy as onto a fresh host.
Concurrent deploys
A deploy planned before another deploy's swap stops at its check (step 4), so a
rollback never undoes a newer deploy, with one exception: a --force redeploy
of the same version, planned after a deploy's swap while that deploy is in its
health check, writes the same current, which the first deploy's rollback
can't tell from its own and can undo. A rollback skipped because another deploy
held the lock leaves current at the failed release if that other deploy then
fails before its own swap; the warning says to check it.
The lock covers the release-directory work only: the restart and the health checks run after the script releases it, so a deploy can report on a host that another deploy has since moved on. Two simultaneous deploys of one version by one connecting user upload to the same name, so either can replace or remove the other's archive; the other then stops at its digest check or finds no archive (exit 1) rather than ship bytes it didn't build.
Security model #
What Zap trusts, what it refuses to trust, and where the walls are.
The threat model in one paragraph
The operator is trusted: whoever runs zap apply --wetrun already holds SSH and sudo on the target, and could do anything by hand. Zap's job is to make what
that person (or the assistant working for them) does reviewable, bounded, and
observable: a config can't act on its own, nothing Zap does to the target
goes unshown except the checks a config declares (they run as written on every
pass, before any review, and Zap can't make
them read-only), a read that fails never passes as "fine", and secrets don't
leak into the places people and agents read. Zap is not a sandbox for hostile
configs you didn't write; see the config is first-party.
The config can only describe
What this means for writing a config is in configs.md. The walls themselves: zap.ts runs in fgro eval, Gro's runtime, a separate binary Zap runs once per evaluation. Its
walls are its own, set up before any of the config's code, or V8 itself, runs:
- a kernel box: Landlock admits reads only under the config's project and
the packages linked into its
node_modules, and seccomp narrows the system calls. No write anywhere, no network socket, no exec, no read outside those directories (/procand/devincluded; metadata such as whether a path exists stays visible), and no signal to another process. There is no setting that weakens it, and on a host that can't hold it (Linux below Landlock ABI 6, an architecture its filters aren't built for) the evaluation fails rather than run without it; - no environment: the evaluator starts with
TZ=UTCand nothing else of yours, reads no variable for its own settings, and gives the config noprocessand no environment API. A variable in your shell can't loosen the sandbox or reach the declaration except throughzap.env(), which is a reference resolved later by Zap, never a value; - only static imports: every
import()is refused, and anode:built-in doesn't resolve. Resolution is strict ESM: a relative import names its extension, and a bare import resolves through thenode_modulesof the project and theexportsof the package found there (never itsmain), under the conditionsgro,importanddefault; - no clock, no randomness, no timers: reading the clock, a random source, or
a fact of the machine throws, and there are no timers; the time zone is UTC
and the locale
en-US(configs.md lists what is refused); - jitless: V8 runs in its interpreter, so
WebAssemblyis absent; - no code from strings:
evalandnew Functionthrow; - bounds: a capped heap and process memory, a 30 second timeout, and a cap on the JSON it may emit.
The config's only output is a JSON declaration that Zap validates against a strict schema. It cannot run commands, write files, or call back into the engine.
The config needs a project. The project is the nearest directory at or above
the config whose package.json holds more than "type" and "sideEffects" (a {"type": "module"} marker in a subdirectory is passed over), outside any node_modules, looking only through directories you own that no other user can
write. Zap finds it before the evaluator runs and refuses, exit 2, when:
- the config file or its directory is writable by every user (for a config reached through a link, the mode of the file it leads to);
- a
package.jsonon the way up can't be read or isn't valid: not a JSON object, a"name"or"type"that isn't a string, a FIFO, socket or device (refused unopened), a link to nothing, or a path through something that isn't a directory; - the walk stops below a directory another user owns (the evaluator's own walk
would go past it) or every user can write, or reaches
/, with no projectpackage.jsonfound. The refusal names where it stopped and why.
The evaluator then finds the project itself and must report the same one, or
nothing it wrote is used. Its box admits the project and the packages its node_modules links in from outside it; a package reached through a directory
every user can write isn't read. A package that isn't installed in the project
doesn't resolve: the search for a bare import stops at the project's package.json, so a node_modules above the project is never searched. npm and
yarn workspaces aren't supported: a workspace member with its own package.json is its own project, and the workspace's node_modules above it
isn't searched.
"You own" means the user Zap runs as. Run as root, that is root, and under sudo also the user who ran sudo (SUDO_UID), so sudo zap apply on a
config in your own project walks your directories as it would without sudo.
The evaluator never runs as root. Run as root, Zap runs it as the user who
ran sudo when that user owns the project, and otherwise as the project's
owner; a project root owns is refused, exit 2, since no user who could already
edit the config is there to run it as. A project that is the evaluating user's
home directory, or holds it, is refused too: the box would admit all of it. The
user it runs as must be able to search every directory down to the project, and
is never dropped into group 0: a drop whose group would be root's is refused.
Dropped to the user who ran sudo (who owns the project), its group is SUDO_GID (the group you ran sudo with; unset or not a number is refused);
otherwise, dropped to the project's owner, that owner's primary group. A drop
keeps no supplementary groups. Its home, and the primary group, come from the
passwd database; a static (musl) zap reads only /etc/passwd and nscd, so a user
known only through LDAP, sssd, or systemd-homed can have no entry there, and the
evaluation is then refused.
The evaluator is pinned. Zap runs the fgro binary --fgro or ZAP_FGRO names, by absolute path, never one found on PATH, and only when its blake3 is
the toolchain pin in the config's zap.lock; zap lock is the one command that
records the pin rather than checking it (lock.md).
The binary is opened once and run through the descriptor that was checked, so
a binary replaced at its path afterward isn't what runs. Its blake3 is read
through that descriptor, or, once the file has settled, from the pin cache
(~/.zap/cache/fgro_pins/, private to the user Zap runs as): an entry there
names the blake3 of a file by its device, inode, size, and modification and
change times, and a file whose metadata matches an entry isn't hashed again.
A write moves the change time, but only to the filesystem's timestamp
granularity, so an entry is written only for a file whose times are a couple of
seconds old, and a clock set back across that margin, or a network filesystem
whose server's clock lags, defeats it. A write to the file itself between the
check and the exec, by anyone who can write it, is caught only on a best-effort
basis. A link at the binary's own name, a wrapper
script, or a set-ID binary is refused.
Reads are the project's. A config reads nothing but the modules it imports,
from its project and the packages the project links. What stays the operator's:
the files in the project and the linked packages are trusted as they are, a
package installed as a directory included. The ownership walk doesn't read ACLs,
and the evaluator reads the disk again after Zap's walk: a project that changed
in between is caught by its report, not prevented. With ZAP_EVAL_CACHE=skip, a
cache hit is served without running the evaluator, so the project walk and the
evaluator's report aren't checked on a hit; the fgro binary is still held to
its pin (cli.md).
The config is first-party
Zap assumes the config and everything it imports are yours. If you ever evaluate
a config you don't trust, the plan it emits is the attack surface: review it as
you would a shell script, because its script resources are shell. A config
can't pull code from the network or import dynamically, and its imports stay
inside its project and the packages it links, so it can't take a project or a
package from a directory every user can write. A linked package is otherwise
trusted wherever it lives and whoever owns it.
What the gates reject before anything runs
Every command that reads a config, including config validate, runs two machine-free gates on the
declaration:
- the secret gate: a secret written literally into the config is rejected when Zap can tell (the same value is both referenced and resolvable in the current environment);
- the structural gate: a malformed or contradictory declaration, such as a NUL byte in a string a shell would see, a duplicate id, or a dangling or cyclic dependency. The full list is in configs.md.
A wetrun adds an env gate: any referenced variable that is unset or empty fails
the run before any side effect, so an empty secret never gets written into a .env file.
Zap changes nothing without a wetrun
No command changes the target unless you pass --wetrun, and no flag approves a
plan for you. A dry run connects, reads, and renders. Reading runs Zap's own
read-only probes and the commands the config declares as checks (a script's check, a verify's assertions, a package's command check, an apt source line's expansion), before any review. Zap
runs them as written: a check that writes, writes. Its only other network
traffic is fetching a missing content pin into the cache on your machine. This
holds with no terminal, in CI, and in an agent loop. zap lock and zap update write zap.lock on your machine, which is their job.
A wetrun executes only the plan you reviewed
A wetrun's fresh plan must match a reviewed one hash for hash, or it refuses
before any side effect; on an interactive terminal it asks you to type the start
of the hash instead, a question nothing typed earlier can answer. The rule, and
what plan_hash binds (the host, the connecting user, a local HOME, every
plain reference's value), is in concepts.md; zap deploy binds its own plan the same way
(deploy.md). So an agent given --expect-plan with the hash you approved, or a pipeline given the hash approved
in a pull request, can execute only that plan. A plan whose host, connecting
user, or deploy health domain resolves through a masked reference gets no hash,
and its wetrun is an error (env.md).
The host a hash binds is the name the config resolves it to, not where ssh takes that name: an ssh config whose HostName, User, or Port for it
changes between the review and the wetrun sends the reviewed plan to the machine
it now names, if that machine's key is a known one. What ssh -G prints is
configuration, not the machine, so the hash doesn't bind it; the machine itself
is held by host-key checking. Binding the machine to a plan would take its host
key's fingerprint, which the hash doesn't hold.
What the hash covers that a preview doesn't print
A preview prints the target and, under each change, what it declares and every
step, with a file's content as a descriptor: its kind, length, and the start of
its SHA-512 (for a template, of the template text as written). plan_hash also
covers:
- a
file's content bytes, and any literal credential; - a resource's
depends_on, and trigger sources; - package names for managers the target doesn't use;
- the text of the scripts Zap's own operations run (the put, link, directory,
extract, snapshot,
pg_hba.conf, and swap-path scripts, and an installer's fetch) and the command each runs in, with its arguments, privilege, and limits, so azapbuild whose scripts, or the way it calls them, differ from the one that previewed computes a different hash.
The hash is not a fingerprint of the zap binary: what it does natively on a
local target, and the few commands it composes directly around a path (the rm -f that clears a pending marker or removes what a failed validated write
created), are its own.
What an approved hash doesn't fix
What Zap can't know when it plans:
- a secret's value (the plan says only that the reference is one);
- the state of the target between the read and the change;
- what the target's shell expands inside a command (
$HOME, a variable in an aptsourceline), which on a local target is Zap's own environment, whoseHOMEan--env-filedoesn't set; - the bytes of a
sourcefile already on the target; - what an
installerURL serves that day; - the path PostgreSQL reports for its
pg_hba.conf; - in a deploy, the artifact bytes and the environment your
buildandpreparecommands run in.
Some commands also run before any review, as the connecting user, on every pass
that reads the machine (plan, detect, apply dry or wet, the deploy
preflight): a script's check, a verify's command assertions, a package's check when it is a command, and an apt source line, which the target's shell expands
to compare with the list, so a $(…) in it runs at zap plan. This is one
reason the config is first-party.
Escaped output
A review is only as good as the text it reads. Everything Zap prints as text
writes what it takes from the config, the environment, the command line, zap.lock, a file under ~/.zap, or the target with control, bidirectional,
and default-ignorable characters (every control character but tab, the line and
paragraph separators, zero-width characters and the like) spelled out as <U+XXXX>, and a tab as spaces, so such text can't move the cursor, overwrite
output, reorder what is shown, or sit in a line unseen. Text that is a block (a
command, a script, what a failed command printed) keeps its lines, each
indented under its first. The --json event stream writes the same characters
as JSON \u escapes. The escape doesn't make look-alikes distinguishable:
homoglyphs, wide or blank spaces, and combining marks print as they are, a long
line still wraps, and text that spells <U+000D> or {env:NAME} prints the
same as what Zap writes for one.
Secrets
- A config never reads the environment. Values are references
(
zap.env('NAME')), resolved by Zap when it acts. A reference holds a name, never a value. No value a reference resolves to, nothing a command prints, and no file's bytes can turn into one. - Which references are secrets, and the forms masking looks for: env.md. A secret's value is masked out of every
output: the text report, the JSON events, warnings, held reasons, drift
observations, deploy step output, and errors. Masking is by value: a secret a
tool prints in another form (the address
sshresolved a secret host name to) isn't found, and a secret under an ordinary name with an ordinary-looking value isn't known to be one untilmask()lists it. - Zap hands every command to its shell on stdin, detection and apply alike.
No command text,
envvalue, or working directory is on the argv ofssh, the login shell, orsh, on either machine, so none shows inps. File content travels on stdin too. A program a command starts still shows its own arguments while it runs, and that includes the ones Zap starts (curl <url>,psql -c <sql>). Give a program a secret through anenvvalue or a file it reads, not as an argument. - A value is substituted into a command as text, into whatever quoting
surrounds the reference. Zap refuses a value holding a single quote, a
NUL, or a reference delimiter (U+FDD0 or U+FDD1) in a command, and a double
quote in a
databaseidentifier, before anything runs: a wetrun checks every planned command first and exits 2 with nothing changed;zap planand a dry run warn. That makes a reference safe inside single quotes, where a shell reads every other byte literally, and Zap and the shipped recipes put every value there, except the options documented as shell words (psql_args,command_args, a stop or reload command). A reference a config author writes outside single quotes, bare or inside double quotes, is read by the shell like the text around it, and keeping it safe there is the author's job. Anenvvalue is quoted after it resolves and carries anything but a NUL and the reference delimiters U+FDD0 and U+FDD1. - The last-run report and
zap.lockhash a secret reference as a fixed token, and the report masks every registered secret out of any other value, so neither can be used to test a guess at a secret. A secret the rule doesn't recognize is hashed by value untilmask()lists it (lock.md). - A fetch error names the URL as the config declares it, its references unresolved, and the content cache names a file from the declared URL, so a token in a URL reaches neither a message nor a path.
remoteandbuildsources can carry a bearerauthtoken; it attaches as a request header to the source's own origin (scheme, host, and port) only: asignature_urlon another origin is fetched without it, and a redirect to another origin drops it. Its resolved value is never hashed and never reaches the lock.- There is no
--show-secrets.
Content Zap fetches
Every URL in a config is https://: a plain http://, file://, or any other
scheme doesn't validate, and neither does a URL carrying user:password@. The
one allowance is http:// to a loopback host for the fetches Zap makes itself,
for a local mirror. Zap follows a redirect only to another https:// URL (a
fetch that has stayed on loopback may be redirected on loopback), so a remote
server can't send a fetch to a plain-http port of the machine Zap runs on,
and a bearer auth goes only to the origin the config names.
Fetched bytes are pinned by blake3, verified against the pin before placement,
and cached by content address. A vendored pin is your own statement of which
bytes to trust. A tracked pin is trust on first fetch: zap lock, zap update,
or the first zap apply --wetrun records the hash of what the URL served then, and every
later fetch must match it. What authenticates that first fetch is TLS, plus a
minisign signature when a build source declares verify, checked before the
bytes are cached or pinned. A signature doesn't fix the version: an older
release the same key signed is caught only by the pin
(resources.md). The recorded pin is in zap.lock and in the plan hash, so it is reviewed like any other change. minisign's exit status is that verdict, so the binary is found once (the
absolute path ZAP_MINISIGN names, else PATH) and run by that path, and one
inside a node_modules directory, or where it or any directory on the way to it
(every one up to /, links followed) is in reach of a group, another account,
or anyone but root and you, is refused; a refusal or a failed check names its
path. With those directories trusted, the gap between the check and the run is
only root's and yours.
zap.lock is reviewed input, like the config: its config_hash detects
staleness, not forgery, so review the lock's diff before committing it
(lock.md).
One thing reaches the target with no pin. An installer is a script fetched
whole over https and then run: whatever its URL serves today, gated only by a check path. A fetch that fails fails the step, and so does one that
serves an empty script; nothing runs. Prefer a build or a pinned remote file when the upstream offers one. An apt_repo's signing key is pinned like remote content: Zap installs only bytes whose blake3 is the pin, and
detection compares the installed key with it
(resources.md).
Writes on the host
Zap installs a file by staging it under a new name in the file's own directory,
setting owner and mode on the open file, and renaming it into place; it never
follows a link at the file's name or at the staged name, and a file's or directory's mode and owner are never set by name. The bytes are checked
against their SHA-512 before the rename, so a transfer cut short installs nothing. A directory's owner
and mode are set on the directory itself; a link at its name is refused. A file keeps the owner, group, and mode it leaves unset from the regular file it
replaces, read from the name without following a link, never with a set-id or
sticky bit; a new one takes its directory's owner and group. A file with a second
name (a hard link) keeps nothing, and neither does anything below a directory
with its sticky bit set or that every account may write (/tmp, /var/tmp, /dev/shm), however deep — Zap walks up from the file's directory by .. to the
root, and checks every directory on the path as written, a link read as what it
leads to: there a file takes what Zap's own write gives, as root over SSH or as
you locally. A mode is never kept from a file with an extended POSIX access list
(ACL), whose mode bits show the list's mask: the write is refused until the
config declares mode. Zap sees a list through getfacl, else through GNU ls's +; a target with neither (stock Alpine) can't show one, and keeps the
mode. Elsewhere, whoever can write the directory can choose what is
kept, as they could replace the file; the files Zap's steps install never keep a
planted owner. So a file with a mode but no owner, written into a directory
another account can write, can end up readable by that account: it inherits that
account's directory, or the account plants a file there of its own for Zap to
keep. Declare mode, owner, and group on any file whose content the
directory's owner or group must not read: a planted file's mode is kept even
when the owner and group are declared, so without a mode the content can land
readable by everyone. A user's authorized_keys is written as that user, and a sudoers file is
installed only after visudo -c accepts it.
A file with a validate command is read before its write, so a failed check can
put it back. One script holds the file open and reads its bytes, mode, and
owner through that descriptor: as root over SSH, and locally as you, escalating
when refused. The change fails with nothing written when the name holds a link,
anything but a regular file (a fifo is refused without waiting on it), or a file
over 16 MiB. A rollback restores the bytes with the owner and group they had,
and their mode without any set-id or sticky bit the declared mode doesn't
carry, so root never makes a file set-id that the config didn't.
One window stays open: between the read's checks and its open, a link swapped in is opened (not read) and the read refused, unless it leads to a fifo, which holds the open until the read's five-minute timeout fails the change. A directory's owner and mode change has the same window and timeout.
Missing directories are made one at a time, each inside the one before it held
open, and given, through its descriptor, the owner and group of the directory it
was made in, so it belongs to someone who could have made it; a link swapped in
for one is refused, with nothing made through it. The owner is given only to the
empty directory Zap just made: a directory renamed over it before it was opened,
by the owner of the directory it was made in, is refused and given nothing.
Anywhere below a directory with its sticky bit set or that every account may
write (/tmp), however deep, on the path as written or where its links lead,
what Zap makes stays its own, as root over SSH or as you locally: an account
that made /tmp/cache, as a directory or as a link to one of its own, can't
have the directories Zap makes there given to it.
Reads of a file's bytes, as root over SSH, read only a regular file of its own:
a name that is a symbolic link, a directory, a device, or a fifo is refused
before it is opened, and what was opened is read only when it is a regular file
and still the file at the name, so no byte comes through a symbolic link. The
checks can't close the instant before the open: an account that can write where
a managed file lives and swaps the name for a symbolic link in that instant has
Zap open what the link names, though not read it, and opening some devices acts
on the machine (a watchdog's), while a fifo holds the read until its timeout. A
regular file with more than one hard link is not read either, whoever owns it:
a hard link is the same file under another name, which an account can make in a
directory it writes, so a root read of the name could read a file that account
linked there. The link count is read from what was opened, and such a file
holds its resource, with the fix: replace it with a copy of its own (a single
link). Keep managed files in directories only root can write all the same. A file's source is read the same way, and a user's own authorized_keys is
read as that user.
Zap does follow a link in a directory component of the path, so a managed path
under a directory someone else can write can be redirected to another
directory: keep root-managed files in directories only root can write. One
such redirection also slips past the shared-directory checks above: a link on
the path that leads through a second link sitting in a directory every account
may write, off both the path as written and the directories it resolves to
(/srv/app a link to /var/shared/app, itself a link the account that can
write /var/shared points at a directory of its own). Neither check passes
through /var/shared, so what is kept there, or given to directories made
there, is that account's choice. Keep the links on a managed path out of
directories others can write. The staged file is made by mktemp, mode 600 whatever the umask or a default access list on the directory would give, and
held open; before a byte is written it must be a regular file of the writer's,
empty, with one name, and still the file at the staged name. A local user who
can write the directory could make the write fail, or stall it by swapping the
staged name for a fifo; they could not send the content, mode, or owner
anywhere, nor have root create a file where a link of theirs points. A run killed outright, or one refused at
its stage check, can leave a .zap.* file beside its target. A target needs /proc.
The same holds for the files Zap's steps install: an apt sources list, the
nftables ruleset, and /etc/fstab are each installed whole by rename, never
edited in place, and so is an apt_repo's signing key. pg_hba.conf is too,
but as the postgres account, not root:
PostgreSQL names the file and postgres owns its directory, so a rewrite as
root would write wherever that account pointed it. The rewrite keeps the file's
group and mode, and refuses a file that doesn't belong to postgres or a
directory it can't write; detection reads the file as postgres as well.
A remote file's or build source's archive is extracted only into a directory
at its own name: a link there is refused. tar runs in a new directory inside a
private staging directory (mode 0700, the extracting user's) beside the
destination. The staging directory is held open from the moment it is checked:
a fresh, empty directory of the extracting user's, mode 0700, at its name,
and not a link. Everything inside it is then reached through the descriptor, so
an account that can write the destination's parent may rename the staging
directory, or put a link at its name, but can't steer where tar writes or
what is removed. What tar extracts belongs to the extracting user, with no
set-id bit on a file and nothing group- or world-writable, whatever the archive
records. Before the rename, still inside the staging directory, the new tree is
given the owner, group, and mode of the destination it replaces, read without
following a link and set on the directory held open, never with a set-id or
sticky bit; then it is renamed into place. Below a directory with its sticky bit
set or that every account may write, checked as for a file (the parent's path
as written and where it resolves), nothing is kept: the new tree stays the
extracting user's, at the mode mkdir gives it. A destination that has an
extended or default access list is refused, a declared mode or not; the list
must be removed (setfacl -b). Such an account can still move the destination
itself, or the tree once it is in place: a parent another account can write is
trusted to that extent. The
archive's contents are trusted as pinned: its author chooses the names inside
it. Neither tar writes through a symbolic link the archive carries. BusyBox tar will make a hard link to a file outside the archive that a member names, so
a new tree holding a file linked from outside it is refused, and removed before
it is renamed into place; hard links among the archive's own files extract.
A remote or build archive is uploaded as the connecting user into a
private staging directory of the run, then extracted as root. The extraction
opens it once and goes on only when the file it holds is a regular file, not a
link, owned by the account that ran sudo, with one link and writable by no one
else. It then copies what it holds into its own staging directory, which only
root reaches, and extracts that copy only when its SHA-512 is the digest of the
pinned bytes Zap passed, so a process running as the connecting user can't
swap or rewrite the archive between the upload and the extraction.
hardened_openrc_service never sources an env_file: its init script runs as
root, so root never parses it: start_pre has the service user check every
line (su, under a 10-second timeout), assigning nothing, and the daemon's own
process, already dropped to that user, exports the values before it runs the
command (recipes.md). A service user that
can write its env file changes its own environment, as under systemd, and can
make a start wait out the timeout or fail; the refusal start_pre prints is
that user's process's words.
A release archive sits in no shared directory on either machine. On yours it is
a file in a private directory of the run; on the target it is installed into
the app path as the connecting user, and the release script extracts only the
file it has open, after checking that it belongs to that user, is writable by
no one else, and has the digest Zap sent. So another account on the host can't
swap or alter it between the upload and the extraction. Nothing checks what
your build put in it. A deploy trusts whoever can write the app path as much as
it trusts the connecting user: they can re-point current. Deploying as root into a path another account can write lets that account redirect what root
creates there, so deploy as the path's owner. A run killed outright, or a
connection dropped between the upload and the script, can leave the archive at
mode 600 in the app path or in your temporary directory.
Authority on the host
Zap's authority is SSH plus the host's sudo. There is no agent, server, API,
or role system to compromise. Privileged commands carry an explicit sudo; on a
local target file operations escalate only when denied, and over SSH they run
under sudo (targets.md). Host
keys are trusted on first use and refused when changed, which holds only while ssh records them: Zap reads your effective ssh config once per target
(ssh -G) and refuses to connect when every UserKnownHostsFile is /dev/null. Every session has agent, X11, and port forwarding, a tunnel
device, a terminal, and LocalCommand turned off, whatever your config says. The host's own sudo I/O log (Defaults log_input, replayed with sudoreplay) is the audit trail of
what ran as root: the plain sudo log records one constant wrapper per
detection batch, and the I/O log captures the script, resolved reference values
included. It never sees the unprivileged login lane, where operator commands
run. A file write appears in the sudo log as one sh -c command carrying
Zap's constant install script, with the path as its argument; the mode and owner
are arguments too, and the content and its digest arrive on stdin, so a host
that logs sudo's input records them.
Fail closed
- A resource Zap can't read is held, not guessed. The run exits 1, and
deploy's preflight refuses to ship (exit 2). A "converged" that wasn't established is never reported. - A pin that isn't current is withheld, so a stale lock can't make a changed resource read converged.
- A reference the comparison needs but can't resolve makes the whole pass unverifiable, exit 1: an empty string can both hide drift and invent it.
- A
filewrite withvalidateis rolled back when the check fails, so a service is never reloaded onto a rejected config. - A failed reload is remembered on the target and re-fired on the next plan.
Security controls are read live, every time
firewall, user, and database guard the host's access surface and are
exactly what gets changed by hand: a ufw default allow incoming at the console,
or a key appended to authorized_keys. Zap keeps no detection cache, so such a
change shows on the next plan rather than hiding behind a stored verdict. The
same holds for security-relevant files: an sshd drop-in, a fail2ban jail, an
nginx site carrying a CSP. The exception is the lists Zap treats as additive
(below): a declared entry that went missing
drifts, but an extra one, such as an injected pg_hba trust line, isn't
read.
Limits and defaults to know
- Firewall allow rules, a database's
pg_hbarules, and a user's groups are additive. Zap asserts that each declared entry is present (and, for a firewall, that the default policies match); an entry added out of band is neither detected nor removed. - A
databaseresource writes nopg_hbarule unless it declaresauth.hosts, and never atrustone: the role is reached over the Unix socket by the OS account of the same name. Atrustrule already in the file (one an older config added) stays until it is removed by hand; on Alpine,alpine_postgres_setuprewritesinitdb's, and any othertrustline, topeer/scram-sha-256. - A
linkwhose path is a regular file reads as absent and is replaced. - A
filewith no managed mode, owner, or group whose path is a symlink to matching content reads converged; the link is left as it is. - Zap follows a symlink in a directory component of a managed path (see Writes on the host).
- A
swap'ssizeis not compared after creation.
Reporting a vulnerability
See the repository's security policy for how to report an issue privately.
How Zap compares #
Zap converges the inside of Linux machines you own and ships your services to them. It is a TypeScript declaration and a Rust engine over SSH, with dry run by default, no agent, and no server. This page puts it next to the tools you might otherwise reach for. It is written for someone with a few servers and a laptop, not a fleet, and it covers when not to use Zap as much as when to.
Facts about other tools name versions where they matter. If something here is out of date, please file an issue.
What Zap is: introduction.md, including the things it does differently. The verdict comes first; the side-by-side tables are at the end.
Use Zap when
- You run a handful of Debian/Ubuntu or Alpine hosts you reach by SSH, and want their packages, users, SSH hardening, firewall, nginx, services, and an on-box PostgreSQL declared in one file.
- You want nothing on the target beyond
sshd,sudo, and a stock shell and core tools: no agent, no Python, no server, no state bucket. - An AI agent or a non-ops teammate will drive the tool, and you want the default command to be harmless, a plan they can show you with a hash, exit codes that separate "converged" from "drifted" from "couldn't tell", and typed events rather than log scraping.
- You'd rather be told "I couldn't read this" than have a tool guess.
- You deploy your own service as release directories with a health gate and rollback, and want the deploy to refuse when the host has drifted.
- You prefer TypeScript with completion over YAML with a template language.
Don't use Zap when
- You need many hosts in parallel or fleet orchestration. One config, one host, resources in order. Ansible, pyinfra, Salt.
- You need cloud resources. VPCs, DNS, load balancers, managed databases. OpenTofu, Terraform, Pulumi; they pair with Zap.
- You need removing a declaration to remove the thing. Zap only reports orphans. NixOS, or Terraform for cloud resources.
- You need macOS, Windows, RHEL, Arch, or network devices. Ansible, chezmoi, mise.
- You want whole-system rollback or reproducibility. NixOS, image-based systems.
- You're container-native. Kamal, Compose, Dokku, Coolify; Zap can still converge the host underneath.
- You need branching on mid-run results. Ansible, or a script.
- Your machine can't run
fgro. It needs x86_64 Linux with Landlock ABI 6 (kernel 6.12 or later). - You need a module ecosystem and community support. Zap is pre-alpha with one maintainer. Ansible.
Head to head
Ordered by how often a reader is choosing between the two.
Versus Ansible
The closest in operational shape: agentless, SSH, no server. The furthest in
control model. An Ansible playbook is an ordered list of tasks; most modules
converge within one task (state: present), but the playbook is a procedure,
and decisions are made at run time against gathered facts, including facts an
earlier task in the same run changed. Zap has no task order you author: you
declare resources and dependencies, and the engine decides create, update, or
skip from detection.
Ansible's --check mode is opt-in and its fidelity depends on each module: command and shell are simulated only when given creates or removes.
Handlers notified by a task are lost if a later task fails, unless you set --force-handlers; Zap remembers an owed reload on the host. Ansible needs
Python on the managed node for nearly every module; Zap needs a shell. Ansible's validate: on copy and template matches Zap's validate on a file.
Reach for Ansible for its enormous module ecosystem, for fleets and inventories, for Windows and network devices, or for run-time procedural flexibility. Zap is narrower and younger by orders of magnitude.
Versus pyinfra
The closest living tool to Zap's shape: agentless, SSH, generates shell so the target needs no Python, written in a real language, two-phase (facts first, then operations). The differences are where the two draw their lines. pyinfra lets deploy code read facts while deciding what to do, so the declaration can depend on the host; Zap seals the declaration before contact and doesn't read the host while deciding. pyinfra shows its changes and prompts before acting, and warns the list may be incomplete; Zap's dry run is the default and executes nothing. pyinfra has no lock file pinning fetched content, no held verdict, and no re-read after acting. pyinfra runs many hosts in parallel; Zap runs one.
Reach for pyinfra for Python, for parallel multi-host runs, or when you want the host's facts to shape the deploy.
Versus Docker Compose, Kamal, and self-hosted PaaS
For a solo developer with a VPS, this is the realistic alternative. Compose, and
platforms like Dokku, Coolify, and CapRover, assume a prepared Docker host and
own the application layer: images, containers, routing, often TLS. They don't
converge the host: SSH hardening, the firewall, users, an on-box PostgreSQL,
unattended upgrades. CapRover and a self-hosted Coolify run a control plane on
your server that you have to keep; Coolify's hosted cloud keeps it for you, over
servers that are still yours to converge. Dokku is git push to one box.
Kamal 2 ships Docker images and does rolling restarts behind kamal-proxy,
which health-checks the new container before switching traffic, a gate that is
stronger than Zap's post-swap health check plus rollback. Kamal has no dry run. kamal setup installs Docker but doesn't otherwise manage the host.
Zap deploys release directories, not containers, and its isolation comes from converging the host (a hardened systemd unit, a dedicated user) rather than from an image. The two are complementary: Zap can converge the box these tools run on.
Reach for Kamal or Compose when you're container-native or want multi-host
deploys; for Coolify or Dokku when a UI or git push is the experience you want
and you're happy to run their control plane.
Versus OpenTofu, Terraform, and Pulumi
The closest in discipline: declarative, converge, review a plan before acting.
Terraform's plan is computed by refreshing a state file that is the
authoritative record of what Terraform owns, and data sources read live
infrastructure during planning, so the desired state is a function of config,
state, and the world. Zap's declaration is a function of the config alone;
detection decides only which declared changes run. There is no state file: the
machine is read every run, and zap.lock pins content, not resources.
Credit where due: Terraform's saved plan (plan -out, then apply <planfile>)
is the most operationally binding plan artifact in the field, and it refuses a
plan whose state has moved since. Zap's wetrun binding does the same job for a
machine, by hash. Terraform also destroys a resource you stop declaring;
Zap only reports it as orphaned.
Pulumi takes "a real language" the other way across the boundary: the program
runs against the live engine, resources register over RPC, and apply() callbacks run mid-deploy. Zap's TypeScript emits data and stops.
Scope differs more than model: both are cloud-resource tools with provider ecosystems. Zap converges the inside of a machine and has no providers. Pair them: OpenTofu for the droplet and DNS, Zap for what's on the droplet.
Reach for OpenTofu, Terraform, or Pulumi for cloud infrastructure, for a removed-means-destroyed model, or for mature team workflows around state.
Versus NixOS
The nearest philosophical kin: both make the declaration deterministic. Nix
makes purity a precondition: builds are sandboxed, the whole system is a
function of a pinned expression, and you adopt the store and the language
wholesale to get reproducibility and atomic rollback. The system closure is a
more complete inert artifact than Zap's declaration, and nixos-rebuild build, dry-activate, and nix store diff-closures let you inspect it before
switching. deploy-rs adds a magic rollback over SSH; nixos-anywhere installs a
machine from nothing.
The difference is prevention versus measurement. Nix prevents drift in what it
owns and is silent about the rest (/var, database contents, mutable users);
activation reasserts without reporting. Zap reads and reports drift on every
run, including the security controls, and repairs it. You keep your distro and
your package manager; you give up atomic generations and whole-system rollback.
Guix System is the same model in Scheme; image-based systems (bootc, Fedora
CoreOS, systemd-sysext) are the other prevention answer: don't converge,
replace.
Reach for NixOS for whole-system reproducibility and atomic rollback, if you're willing to adopt the model end to end.
Versus Puppet, Chef, and Salt
The same convergence model, a different operational one: agents that pull and converge continuously, usually against a server, with mature resource models and fleet tooling. All three run masterless too.
Puppet deserves specific credit: it compiles a catalog, an inert JSON
resource graph, before applying, and the catalog can be compiled without
applying and diffed. The contrast with Zap is that the catalog's inputs include
facts gathered from the node, which is exactly the host-reactivity Zap declines
to model (below). Chef's why-run is
best-effort by its own documentation. Salt's test=True and state.show_highstate give an inspectable compiled state; salt-ssh is
agentless but needs Python on the target unless it ships Salt's bundled runtime
(--relenv).
Puppet and Chef have both moved their official builds behind commercial terms, with community forks (OpenVox, Cinc) carrying the free builds. Salt is actively released under Broadcom, with many modules moving to community extensions.
Reach for one of these for a large fleet that wants continuous, pull-based convergence and a deep existing ecosystem.
Versus Capistrano, Deployer, and Mina
On the deploy axis the comparison shifts: this is release push, not
convergence. Capistrano and its kin ship a versioned release into releases/ and swap an atomic current symlink, the same model Zap's deploy uses, with keep_releases (default 5) and a manual deploy:rollback. None has a
built-in health gate or automatic rollback; Capistrano usually clones git on the
server, where Zap builds locally and uploads a tarball. Mina generates one bash
script per deploy and prints it with --simulate, as Zap's dry run prints its
remote script. Mina is dormant: no release since 1.2.5.
Zap's additions are the drift preflight (the deploy refuses to ship onto a host whose convergence config has drifted), the automatic rollback on a failed health gate, and that deploy rides the same engine as convergence.
Reach for Capistrano or Deployer for a mature multi-host release tool with a plugin ecosystem in the Ruby or PHP world.
Versus plain shell and cloud-init
The honest baseline: a setup.sh, a Makefile, a cloud-init file. These are
imperative bootstrap: top to bottom, no idempotency guarantee, no notion of
current state, no dry run, and cloud-init runs most modules once per instance
rather than as a loop. Zap's value over this baseline is exactly what
convergence buys: skip what's done, re-run safely, preview by default, report
after.
Reach for plain shell when the task is one-shot, tiny, or so bespoke that a resource model is more ceremony than it saves.
Personal machines: mise, chezmoi, home-manager, Brewfile
Zap also sets up a developer's own machine: build tools, a terminal, dotfiles,
packages. The dedicated tools are better at their one job: mise pins tool
versions with a lockfile, chezmoi diffs and templates dotfiles across machines
and supports macOS and Windows, home-manager brings the Nix model to a user's
environment, brew bundle declares macOS packages. Zap's
case is one model for the laptop and the servers, with the same file, pkg, build, and script resources, and the same dry run. It doesn't run on macOS.
How each tool reacts to the host
Zap's declaration is sealed before the machine is touched: the config runs with no network and no environment, and detection then decides only which declared changes run. That is why there's no state file, why the plan has a stable hash a wetrun can be bound to, and why secrets stay inert until use. It splits "reacting to what's on the host" into three cases:
- Detection: gating a declared action on observed state ("install only if
missing", "restart only if the config changed"). This is the convergence
loop itself, plus a
script'scheck,verifyassertions, and triggers. Under convergence you don't write "if X, do Y" when Y is idempotent; you declare Y and detection skips it. - Derivation: desired state computed from host facts ("swap is twice the
detected RAM", "apt on Debian, apk on Alpine"). Terraform does this with
datasources, Puppet and pyinfra with facts, cdist with explorers. Zap doesn't read the target during eval; ascriptthat computes on the target covers these cases. - Mid-run reactivity: decisions from results a prior step produced. That is
orchestration (Ansible's
when:over run-updated facts, a shell script), and Zap doesn't do it. Put that logic inside onescript, or compose Zap with a tool built for it.
A script's run is arbitrary shell, so the cost is legibility, never
capability. Two caveats: on a local target the config runs on the machine it
describes, so it could read host state at eval time (outside the model, not
prevented); and determinism is by construction for the common sources (the
clock and randomness throw, the time zone and locale are fixed) rather than
proven, where Nix's sandboxed evaluation is the stronger claim.
Also in the space
cdist: agentless SSH push, type-based, generates shell from explorers run on the target; the closest historical kin, now low-activity. mgmt: a declarative, event-driven engine with a real-time graph that converges continuously, Zap's polar opposite on timing. Fabric, Rex, Puppet Bolt / OpenBolt: imperative remote task runners. Kubernetes: the purest convergence model there is, continuous reconcilers over etcd, and for a handful of VPSes the cost is the cluster itself. Dagger: a CI/CD pipeline engine, not a host tool; Zap isn't a CI engine.
At a glance
One table per axis. Zap's cells are exact; the others are our best understanding and are kept general on purpose.
Model: how each tool decides what to do
| Tool | Control model | Desired state fixed when | Can read the target while deciding? | Plan artifact | Plan binds the apply? |
|---|---|---|---|---|---|
| Zap | converge, one-shot | at eval, before contact | no (SSH); possible but outside the model (local) | changeset with a blake3 hash | yes: a wetrun must match the reviewed hash |
| Ansible | ordered idempotent tasks | at run time, per task | yes (facts, register) | none; --check simulates | n/a |
| pyinfra | ordered operations over facts | after fact gathering, before execution | yes (facts) | the change summary before the prompt | no |
| Terraform / OpenTofu | converge, graph | plan time | yes (data sources) | saved plan file; JSON via show -json | yes: apply <planfile> refuses a stale plan |
| Pulumi | converge, live program | during the update | yes | preview; saved update plans (experimental) | experimental |
| Puppet | converge, graph | catalog compile | yes (node facts) | catalog (JSON), compilable without applying | the agent applies the catalog |
| Chef | converge, ordered resources | compile phase on the node | yes (Ohai, arbitrary Ruby) | none; why-run is best-effort | n/a |
| Salt | converge, requisites | render time | yes (grains, pillar) | highstate data (show_highstate) | no |
| NixOS | build then activate | eval time, pure with flakes | no | the system closure, a hashed store path | yes: you activate the closure you built |
| cdist | converge, types to shell | after explorers run | yes (explorers) | generated shell (-n) | no |
| Kubernetes | continuous reconcile | on API write | n/a | kubectl diff, server-side dry run | no |
| Capistrano / Deployer / Mina | release push, procedure | run time | yes | none (Deployer --plan lists tasks; Mina --simulate prints the script) | n/a |
| Kamal | container release push | run time | n/a | none | n/a |
| Compose / Dokku / Coolify | container apps | on deploy | n/a | none | n/a |
Operations: what you run and where
| Tool | Operator machine needs | Target needs | Server or state backend | State of record | Reaches the target by | Many hosts |
|---|---|---|---|---|---|---|
| Zap | one binary plus fgro (Gro's runtime), ssh | sshd, POSIX shell, sudo | none | the live machine, plus zap.lock for pins | SSH, or local | one host per config, resources in order |
| Ansible | Python | SSH plus Python (except raw) | none (AWX optional) | the machine | SSH; ansible-pull | yes, inventory, parallel forks |
| pyinfra | Python | SSH plus shell | none | the machine | SSH, local, Docker | yes, parallel |
| Terraform / OpenTofu | one binary plus providers | n/a (APIs) | local state file by default; remote backend with locking for teams | the state file | provider APIs | n/a: resources, not hosts |
| Pulumi | binary plus a language runtime | n/a | Pulumi Cloud by default, or self-managed | the state backend | provider APIs | n/a |
| Puppet / OpenVox | server, or puppet apply | agent with bundled Ruby | server plus PuppetDB, or masterless | the machine, plus catalogs and reports | agent pull every 30 min | yes, fleet scale |
| Chef / Cinc | server, or local mode | agent with bundled Ruby | Chef Infra Server (the open-source server is end-of-life; Chef 360 or the Cinc Server fork) | the machine plus node objects | agent pull | yes |
| Salt | master | minion, or salt-ssh with Python on the target | master, or masterless | the machine plus job cache | ZeroMQ or SSH | yes |
| NixOS | Nix | NixOS | none | the Nix store and generations | local, --target-host, deploy-rs, colmena | via deploy-rs or colmena |
| cdist | Python | SSH plus shell | none | the machine | SSH | yes, parallel |
| Kubernetes | kubectl | a cluster | etcd | etcd | API server | yes |
| Capistrano / Deployer / Mina | Ruby or PHP | SSH, git for Capistrano | none | releases/ on the host | SSH | yes |
| Kamal | Ruby, or its Docker image | SSH plus Docker (installed by kamal setup) | a container registry | containers on the host | SSH plus registry | yes, rolling |
| Coolify / CapRover | a browser | Docker plus their control plane on a server | on-server control plane | its control plane | its UI or agent | yes |
| Dokku | git | Docker plus Dokku | on the server | Dokku's config | git push | one server (schedulers for more) |
Safety: what protects you from a bad run
| Tool | Dry-run posture | Dry-run fidelity | A read that fails | After acting | Secrets at rest | Rollback |
|---|---|---|---|---|---|---|
| Zap | default; --wetrun to act; on a terminal with nothing reviewed, asks for the start of the plan_hash (no flag answers it) | full detection, nothing executes | held: out of the plan, exit 1 | re-detects; anything not converged is RESIDUAL, exit 1 | none stored; references resolved at apply and masked | deploy: previous release, health gate; convergence: re-apply the old config |
| Ansible | opt-in --check (+ --diff) | per module; command/shell need creates/removes | task error or unreachable | none built in (assert tasks) | Ansible Vault, encrypted in the repo | none built in |
| pyinfra | shows changes, then prompts (-y skips); --dry exits after | fact-based, may not include every change | error | none | user-managed | none |
| Terraform / OpenTofu | plan, then prompt (-auto-approve skips) | high; "known after apply" unknowns | refresh fails the plan | next plan shows drift | plaintext in state (OpenTofu 1.7+ can encrypt; Terraform ephemeral values from 1.10) | re-apply the old config |
| Pulumi | preview, then prompt (--yes skips) | high | error | none | encrypted per stack | none built in |
| Puppet | opt-in --noop | high for native types; exec guards run, commands don't | resource fails, run continues | next run | Hiera eyaml and the like | re-apply the old catalog |
| Chef | opt-in --why-run | best-effort, with assumptions; Chef's own blog calls it harmful | error | next run | encrypted data bags, Vault | none |
| Salt | opt-in test=True | per module | error | none | pillar, GPG renderer | none |
| NixOS | build or dry-activate before switch | the build is exact; activation partly | the build fails before touching the system | n/a | world-readable store unless agenix or sops-nix | atomic generations, boot menu |
| Kubernetes | --dry-run=server, kubectl diff | high | reconciler retries | continuous | base64 by default; encryption at rest opt-in | rollout undo |
| Capistrano / Mina | Mina --simulate; Capistrano none | printed script | error | none | linked_files | deploy:rollback, manual |
| Kamal | none | n/a | error | kamal-proxy health-checks the new container before switching | .kamal/secrets with password-manager adapters | kamal rollback <version> |
Scope and maturity
| Tool | Primary scope | Authoring | Config typed? | OS coverage | Maturity |
|---|---|---|---|---|---|
| Zap | inside a Linux box, app deploy, dev tools | TypeScript pure function to JSON | yes: TS types plus a strict schema at plan | Debian/Ubuntu, Alpine | pre-alpha, one maintainer, small fixed resource set, no plugins |
| Ansible | machine config and ad-hoc ops | YAML plus Jinja2 | lint only | very broad, Windows and network devices included | huge; Galaxy collections |
| pyinfra | machine config | Python | Python typing | broad POSIX | small to medium; active (3.x) |
| Terraform / OpenTofu | cloud resources | HCL | schema-validated | n/a | huge; provider registry. Terraform is BUSL-licensed from 1.6; OpenTofu is the MPL fork |
| Pulumi | cloud resources | TS, Python, Go, C#, Java, YAML | yes | n/a | large |
| Puppet / Chef | fleet machine config | Puppet DSL, Ruby | partial | broad, Windows included | large and mature; both moved their official builds behind commercial terms, with community forks (OpenVox, Cinc) |
| Salt | config, remote execution, events | YAML plus Jinja and other renderers | no | broad | large; actively released under Broadcom, many modules moving to extensions |
| NixOS | the whole OS | Nix | module system checks at eval | NixOS (nix-darwin for macOS) | large; steep curve |
| Kamal | container app deploy | YAML | no | any Docker host | medium; 37signals-backed |
| Compose / Dokku / Coolify | container apps | YAML or a UI | no | any Docker host | large |
| mise / chezmoi / home-manager | dev tools, dotfiles | TOML, templates, Nix | no | Linux and macOS (chezmoi: Windows too) | medium to large |
FAQ #
The config
Why TypeScript for the config? Because the config needs loops, constants, functions, and types, and inventing a DSL for those is how YAML grows a template language. TypeScript has them, editors understand it, and the shipped recipes are ordinary functions. The config only describes: it runs in a sandbox and emits JSON. Zap evaluates it once, before it reads the machine; what executes is the plan built from that JSON, and none of your TypeScript runs on the target.
What runs the config? Can I use Node, Deno, or Bun? fgro eval, Gro's
runtime. Zap is a compiled Rust binary and can't run TypeScript itself, so fgro evaluates the config in a kernel sandbox that reads only the config's
project and the packages it links (the sandbox).
Each project pins one fgro build by hash in zap.lock. Node, Deno, and Bun
have no such sandbox, so Zap doesn't support them. Nothing runs on the target
but shell.
Where are secrets stored? Nowhere Zap owns. They live in your environment
or an env file you keep out of version control, referenced with zap.env('NAME'), and
resolved when Zap acts. Resolved values are masked in every output, and the lock
file and last-run report hold a fixed token for each recognized secret
(env.md; list others with zap.mask()).
What is zap.lock and should I commit it? It pins the fgro build that
evaluates the config, and fetched content (a remote file, a build source) to
the hash of the bytes you approved. Commit
it with zap.ts. It changes only when you run zap update, zap lock, or an apply --wetrun that has a tracked source to pin. lock.md.
Running it
Why is the dry run the default? So the safe thing is the zero-effort thing,
and so a script, a CI job, or an assistant can't act by accident. Tools that
default to acting rely on an interactive prompt, which is exactly what gets
auto-approved. Here --wetrun is the act, and a wetrun with nothing reviewed
asks for the first characters of the plan's hash, which nothing can type before
the plan is on the screen.
What does --wetrun refusing mean? A wetrun executes only the plan you
reviewed. If its fresh plan doesn't match the most recent preview on that host,
or the hash given to --expect-plan, it shows the plan, changes nothing, and
exits 2; running it again executes if nothing moved. On an interactive terminal
without --expect-plan, it asks instead: type the start of the plan's hash to
run it. concepts.md.
Why did zap plan exit 0 when there are changes, but zap apply exit 2? plan reports; it doesn't judge. apply judges: 0 converged, 2 changes pending,
1 something couldn't be established. Use apply in a script that needs the
verdict. cli.md.
What is undetermined? Zap couldn't read the resource and refuses to guess.
It is held out of the plan, reported with its reason, and the run exits 1. The
usual causes: a remote path using ~ with no home declared, a tracked remote file with no lock entry, a probe that failed or timed out, sudo that
wouldn't authenticate. Fix the cause and plan again; nothing carries over. concepts.md.
Why does a script run on every apply? Its check fails on a converged
host. The check is the whole idempotency contract: make it exit 0 exactly
when the work is done. After a wetrun Zap runs the check again and reports RESIDUAL if it still fails.
Why does apply write to /var/lib/zap/pending/ on my host? Each marker
there is a restart or reload a service still owes, written before the change that
triggers it and cleared after. It is how a reload a failed run owed survives to
the next plan (concepts.md). The rest of what Zap leaves
on a target, beside what you declared, is listed in install.md.
Scope
Does Zap need anything installed on the target? sshd, a POSIX shell with
coreutils or BusyBox, and sudo. No agent, no Python, no runtime. targets.md has the per-distro list.
I removed a resource from the config. Why is it still on the machine? Zap
never removes what you stop declaring; it reports it as orphaned in zap status.
To remove something, declare the removal (a script whose check confirms it
is gone, or sudo: false on a user). This is the same posture as Ansible and
Puppet, and the opposite of Terraform and NixOS.
Can one config manage several hosts? One target per config. For a few hosts, one config per host (sharing modules), one env file per host, a shell loop. For a fleet with parallel execution, Zap is the wrong tool; see comparison.md.
Does Zap work on macOS, Arch, Fedora, Windows? No. Debian/Ubuntu and
Alpine. pacman, brew, and launchd parse in the schema but have no backend,
so their resources read undetermined. Running Zap from macOS against a Linux
host is unsupported: fgro's sandbox needs Linux, so your side does too.
Is Zap stable? No: pre-alpha, one maintainer, and the schema and CLI change without compatibility layers. stability.md names the surfaces whose breaks a release calls out. Read security.md before pointing Zap at a machine you care about.
Agents and alternatives
Can an AI agent run Zap? Yes, and it is designed for that: the default
command is read-only, the plan is a hash-named fact the agent can show you, the
wetrun executes only that plan, and --json streams typed events. Give the
agent zap plan and zap apply freely and keep --wetrun for the human, or
let it wetrun with --expect-plan set to the hash you approved.
How is this different from a shell script with if guards? That script is
reaching toward what Zap declares: detection (skip what's done), idempotent
re-runs, a preview before acting, and a report after. Zap adds the parts a
script never grows: a refusal to guess when a read fails, a re-read after acting,
secrets masked everywhere, and a review bound to the act. script resources let
you keep the shell where it is the right tool.
How is this different from Ansible / Terraform / NixOS / Kamal? comparison.md, with tables.
What you can rely on #
Zap is pre-1.0. Anything can change between minor versions, and nothing is kept for compatibility: no deprecation period, no aliases for renamed things. What this page promises is narrower: which surfaces count as Zap's interface, so a change to one is named in the release notes, and what an upgrade throws away on purpose.
The interface
A change to any of these is a breaking change, and the release notes name it.
| Surface | Where it's defined |
|---|---|
The zap.ts config: the builder (zap.pkg, zap.file, …), every resource kind and field, and zap.env('NAME') references | @fuzdev/zap/zap.ts, @fuzdev/zap/schema.ts, resources.md, env.md |
| How a config is evaluated: a pure function of the builder, in a sandbox, and what it can't reach | configs.md |
Recipe module paths and their exported functions, including the debian_* / alpine_* pairs | recipes.md |
| CLI commands, flags, and exit codes | cli.md |
The --json event schema a wetrun emits | cli.md |
The zap.lock format ("version": 2) | lock.md |
The env file --env-file reads | env.md |
The deploy layout on the target (releases/ and its release names, current, shared/, deploy_info.json, the .zap-deploy.lock file) and the health and readiness contract | deploy.md |
Zap's state on the target: one marker per owed restart or reload under /var/lib/zap/pending/, a build's <output>.zap-build provenance sidecar, and an extracted remote file's .zap_remote_hash sentinel | concepts.md, detection.md |
zap.lock is the one file Zap writes that you commit, so it carries the one
migration promise: if its format version ever moves, Zap will read the old
version and rewrite it in the new one, so you won't re-mint it by hand.
Discarded on upgrade, by design
These are derived by the binary that made them. A new version may compute them differently, and when it does the old ones stop matching. None of them breaks silently: Zap plans again, and the review gate asks for a new review.
- Plan hashes. A
plan_hashfrom one Zap version may not bind a wetrun on another. After an upgrade, a recorded preview or an--expect-plan <hash>can refuse; preview again and review the new plan. - The last-run report and the deploy preview under
~/.zap. A record in another format is ignored, the same as having none. - Each lock entry's
config_hash. When the way a declaration is hashed moves, every tracked entry reads stale:zap applystops with an error namingzap update <id>, and the read-only commands hold those resources until it runs.zap updatefetches again and re-mints. Because it fetches, a source whose bytes changed upstream comes back with a new pin, so read the lock's diff before you commit it. - Provenance sidecars and extract sentinels on the target. When what they
record moves, each
buildrebuilds and each extractedremotefile re-extracts once, on the next wetrun. - The evaluation cache. Internal, and rebuilt as needed.
Not part of the interface
- The human-readable output: plan rendering, wording, banners, layout. A script
should read exit codes and
--jsonevents, not text. - The Rust crates. They aren't published; the
zapbinary is the interface. - Anything the docs describe as internal or as a current limitation.
CLI reference #
Every zap command, its flags, what it reads and writes, and its exit codes. For what the commands mean, see concepts.md; for a first run, see getting-started.md.
zap <command> [flags] [config]Every command that takes a config defaults it to ./zap.ts. zap with no
command prints the loop and exits 0; zap --help prints the command list, and --help on any command prints its flags.
Commands at a glance
| Command | Does | Touches the target | Writes |
|---|---|---|---|
zap plan | detect, then diff desired against live | reads | last-run report |
zap apply | dry run: the plan, as apply would run it | reads | last-run report |
zap apply --wetrun | execute the reviewed plan, then re-detect | changes it | zap.lock, pending-trigger markers on the target, last-run report |
zap deploy | dry run: the release plan, every step in full | reads current over SSH | the deploy preview |
zap deploy --wetrun | execute the reviewed release plan: build, upload, swap, restart, health-check | changes it | releases, current, deploy_info.json and the deploy lock on the target, last-run report (preflight); withdraws the deploy preview |
zap detect | the live verdict per resource, no diff | reads | last-run report |
zap status | the last run's verdicts, offline | no | nothing |
zap config validate | eval the config and run the machine-free gates | no | nothing |
zap lock | pin the fgro build and rebuild zap.lock from upstream | no (fetches URLs) | zap.lock |
zap lock --check | classify zap.lock against the config | no | nothing |
zap update [ids…] | re-fetch and advance lock pins | no (fetches URLs) | zap.lock |
zap version | print the version | no | nothing |
All of them but version evaluate the config first, with the fgro build zap.lock pins (zap lock pins the one it is given), and run the two
machine-free gates: a hardcoded secret literal or a structural error (a duplicate id, a
dangling depends_on, a cycle, service intent on a target with no service_manager, …) fails every command with the same message. See configs.md.
Shared flags
| Flag | On | Meaning |
|---|---|---|
--fgro <path> | every config command | the fgro binary that evaluates the config, by absolute path; overrides ZAP_FGRO. Under sudo, which resets the environment, the flag is the only way to name it (install.md). |
--env-file <path> | every config command | load NAME=VALUE lines into the operator environment before anything resolves (env.md) |
--no-cache | detect, plan, apply, deploy | re-evaluate the config even under ZAP_EVAL_CACHE=skip, which otherwise serves a cache hit; by default every command evaluates fresh. The other commands that read a config take no --no-cache and serve a hit under skip. Only the config's evaluation is cached; the target is always read live. |
--bootstrap | detect, plan, apply, deploy | connect as the target's bootstrap_user instead of user; a different plan from the primary user's (targets.md) |
--json | apply, deploy | stream events as JSON lines to stdout during a wetrun (no effect on a dry run); a wetrun that ends before its first event prints its text report to stderr |
--wetrun | apply, deploy | execute. Without it neither changes the target. |
zap plan
zap plan [--diff] [--no-cache] [--bootstrap] [--fgro <p>] [--env-file <p>] [config]Detects every declared resource live and renders the changeset: one [+] (create) or [~] (update) line per resource, its steps under it, and the plan_hash at the end. --diff adds per-resource current → desired attribute lines above a drifted resource's steps; every change still shows its
steps, so the view prints everything the plan_hash covers, as zap plan does.
Under the header come the lines naming the machine and environment the plan is
for. They are part of the plan and its hash, so a preview made as one user or
under one HOME doesn't bind a wetrun as another: sudo zap apply --wetrun runs as root and refuses a review made without it.
| Line | Printed | Shows |
|---|---|---|
host: | remote, when the declared host is a reference | the host it resolves to |
user: | remote | the connecting user, or (none — ssh chooses) |
user: | local | the user Zap runs as |
home: | local | the HOME a ~ resolves to (an --env-file's value included), or (none — HOME is unset) |
target: | always | the declared target as one line of JSON ({"local":true}), a reference in its exact form ({"$env":"NAME"}) |
references: | when the changes use references | one line each: NAME = value, NAME is a secret: …, or NAME is unset or empty: …. A value past 200 characters is cut, with the rest's length and the start of the whole value's blake3. |
Under each change come the lines that say what it declares (its name: when it
has one, a file's path:, mode:, and content: descriptor, a link's to:, a
script's check:, cwd:, and env:, an apt_repo's key: URL and the pin: its bytes must have, a swap's size:, a pipeline's max_concurrency: and its step: ids and step name: labels), then its steps. A content: descriptor's
SHA-512 is of the text as written: for a template, the template before its
variables are filled, followed on the same line by its vars and their kinds, each as JSON.
Multi-line text prints indented under its first line, and control and invisible
characters print as <U+XXXX> (security.md).
A step line starts with >, #, or =. > is a command Zap runs as written. # is never run as written: it is a verify assertion Zap checks itself, or a
step the target's manager has no command for. = is one of Zap's own
operations, named with what it acts on and never printed as the script that
performs it:
[+] deploy (user)
> id -u -- 'deploy' >/dev/null 2>&1 || sudo useradd -m -d '/home/deploy' -s '/bin/bash' -- 'deploy'
> sudo chown -h -- 'deploy:' '/home/deploy'
> sudo usermod -s '/bin/bash' -- 'deploy'
= dir /home/deploy/.ssh (mode 700, owner deploy:deploy, as root, created if missing)
= put /home/deploy/.ssh/authorized_keys (mode 600, as deploy; 126 bytes, sha512 ef415c5c6a4db94b…)
text: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyOne deploy@laptop
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyTwo deploy@ci
> sudo usermod -p '*' -- 'deploy'
= put /etc/sudoers.d/deploy (mode 440, as root, checked by visudo; 86 bytes, sha512 2434fdb3c08c85f2…)
text: deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart app, /usr/bin/journalctl -u app
[+] pgdg (apt_repo)
key: fetched from https://www.postgresql.org/media/keys/ACCC4CF8.asc
pin: blake3 bb84ad7105529a9bf29a01dca1d2e400576031b070e396a9f0c669bf75457192
= put /etc/apt/sources.list.d/pgdg.list (mode 644, as root)
from: the line below, as the target's shell reads it inside double quotes after reading /etc/os-release: a variable in it expands and a $(…) in it runs, at every plan too
line: deb [signed-by=/usr/share/keyrings/pgdg.asc] https://apt.postgresql.org/pub/repos/apt $VERSION_CODENAME-pgdg main
> sudo apt-get update= put <path>installs a file (security.md). The parentheses give its mode, the account the write runs as, and a check the staged file must pass. Text Zap holds prints whole undertext:, less its final newline, with its length and the start of its SHA-512; a text with no final newline says so in the parentheses. Text the target produces has afrom:line saying where it comes from and what skips the put. Text the config supplies for it prints underline:: an apt source line (which the target's shell expands, at every plan as well as on apply, so a$VERSION_CODENAMEin it is read there and a$(…)in it runs), an/etc/fstabentry.= run the script fetched from <url> (piped to <shell>)is aninstaller: the URL and the shell as the config declares them.= dir <path>creates a directory if it is missing and sets its mode and owner.= check <path> (not a link, below directories only root writes, as root)comes first in aswapfile's creation, and refuses a link at the path or a directory on the way to it that another account can write (resources.md).= pg_hba rule: <line>adds the line to the server'spg_hba.conf, as thepostgresaccount, unless it is already there. A line it adds is put in force by reloading the server's configuration; a line already there reloads nothing. A file that doesn't belong topostgresis left unchanged and the step fails, and so does a file the server can't parse (pg_hba_file_ruleslists anerror, checked before the reload), a reload it doesn't confirm, or a reload it doesn't load within 30 seconds.
A = step that fails is reported by the same words, in the run's report and in
the change_failed event's error: put /etc/sudoers.d/deploy (mode 440, as root, checked by visudo) exited 93: ….
A pass that can't establish a verdict says so before the plan, under an UNDETERMINED, UNVERIFIABLE, or LOCK banner.
Exit codes:
| Code | Meaning |
|---|---|
| 0 | every resource was read, and every reference and pin resolved. Pending changes still exit 0: plan reports, it doesn't judge. |
| 1 | something is unestablished: a held resource, an unresolved reference, or a lock standing error |
The pass is recorded as a preview, so a following zap apply --wetrun binds to
its plan (concepts.md).
A plan whose host or connecting user resolves through a masked reference names
no one machine: it is shown under an UNBINDABLE note with no plan_hash, and
is not a preview. When it is the host, the pass also keeps no last-run report
and says so under an UNRECORDED note.
zap apply
zap apply [--wetrun] [--expect-plan <hash>] [--json] [--no-lock]
[--no-cache] [--bootstrap] [--fgro <p>] [--env-file <p>] [config]Without --wetrun, the same pass as plan, rendered as what apply would do,
and exit codes that judge the result (below). It also resolves any missing
tracked pin over the network and reports it as resolved, not yet written to zap.lock; the write waits for a wetrun.
--no-lock leaves the lock's entries alone: no tracked pin is read, nothing is
fetched to pin a tracked source, and nothing is written. A tracked source then
has no pin, so its resource holds; a config with none runs as usual, offline.
The toolchain pin is still read, and without one the command refuses as any
other does.
With --wetrun, apply:
- rejects the run if any variable the config references is unset or empty, or if the target's host or connecting user resolves through a masked reference;
- detects and plans;
- checks the plan against the review (next section) and refuses if it doesn't match, or on an interactive terminal shows the plan and asks;
- refuses if any planned command can't take a value its reference resolves to
(a single quote in a command, for one), naming the resource and the variable;
zap planand a dry run warn about the same thing ahead of time; - claims the preview it bound to, so one preview covers one wetrun: of two wetruns started on one preview, the one that claims it first goes on and the other stops with nothing changed (exit 2);
- re-checks that every reference the plan recorded still resolves to the recorded value, refusing (exit 2) if one moved while the run was in progress;
- writes
zap.lockwhen the config has tracked pins; - executes the changes in dependency order, stopping at the first failure, writing a pending-trigger marker on the target before the first source of a triggered service and clearing it after the service's change;
- re-detects every resource and reports any that still isn't converged as
RESIDUAL.
Binding to the review
A wetrun executes only the plan you reviewed: the plan it computes must equal,
hash for hash, the one named by --expect-plan <hash> or the one in the most
recent preview on this host. Otherwise it refuses, changes nothing, and
exits 2. The rules are in concepts.md.
On an interactive terminal, a wetrun whose plan has no review (no preview, or a
preview of a different plan) and no --expect-plan shows the plan and asks instead:
$ zap apply --wetrun
apply — target local (wetrun, awaiting confirmation)
user: ops
home: /home/ops
target: {"local":true}
[+] notes (directory)
path: ~/notes
mode: 755
[+] hello (file)
path: ~/notes/hello.txt
content: inline (15 bytes, sha512 0c7463f032f1dc98…)
UNREVIEWED — this plan is not the one that was reviewed.
no plan has been previewed on this host.
plan_hash: 5bb79cc9141942676c784abba55826f12be51b3feb86f5f0cf77666545674d61
Apply this plan? Type 5bb79cc9, the start of its plan_hash, to confirm: 5bb79cc9
apply — target local (wetrun, confirmed)
user: ops
home: /home/ops
target: {"local":true}
ok notes
ok hello
applied: 2 ok, 0 failed, 0 skipped (of 2)
plan_hash: 5bb79cc9141942676c784abba55826f12be51b3feb86f5f0cf77666545674d61The plan and the question go to standard error; the report goes where it
always does. The answer is the first eight characters of the plan's plan_hash, in either case. Input typed before the question appears is
discarded, so neither a y nor an earlier run's answer typed ahead while Zap
reads the machine runs anything. Those
characters execute the plan shown and nothing else; any other answer (a y,
the whole hash, an empty line, the end of the input) declines: the plan is
rendered again under a DECLINED banner, recorded as the preview, and the exit
is 2, as for a refusal. There is no flag that answers for you. Interrupting at
the question (Ctrl-C) ends the run with nothing executed and nothing recorded
as a preview, so the next wetrun asks again. It never asks under --json, for
a mismatched --expect-plan, for a plan with no hash, or for a plan a command
can't run.
Scripting. The question appears only on an interactive terminal, for
a plan with no review. A script stays non-interactive by previewing first (a dry run,
then the wetrun), by passing --expect-plan <hash>, or by running with
standard input that isn't a terminal (< /dev/null). A non-interactive wetrun
with no review of its plan refuses, and --json never asks.
--expect-plan makes the binding explicit: the hash from the plan you reviewed,
perhaps in a pull request or an agent's transcript. The match is exact. A dry run
rejects the flag. zap deploy follows the same rule against its own preview
(below).
Exit codes:
| Code | Dry run | Wetrun |
|---|---|---|
| 0 | no changes pending | every change completed, and the re-detection read everything converged |
| 2 | changes pending; --expect-plan without --wetrun | refused: the plan was not the reviewed one (or was declined at the prompt), its preview was claimed by another wetrun first, or a command can't take a resolved value; or an error before anything runs: the host or connecting user is behind a masked reference |
| 1 | a held resource or an unresolved reference | a failed change, a residual resource, a failed re-detection, a held resource, or a preview that couldn't be withdrawn |
zap deploy
zap deploy [--wetrun] [--expect-plan <hash>] [--force] [--skip-build]
[--skip-preflight] [--json] [--no-cache] [--bootstrap]
[--fgro <p>] [--env-file <p>] [config]Ships a release to a remote target: build locally, tarball the artifacts,
upload, extract into releases/<version>, swap the current symlink, restart
the service, poll health. Full mechanism: deploy.md.
A dry run reads zap.lock, connects over SSH to read the live current symlink, prints the plan (the resolved host, the declared target, the tree and
preflight state, and every step with its command, script, paths, or URL), ends
with the plan_hash, records the plan as this host's deploy preview, and
exits 0. If the live release is already this version it reports server already at this version, prints no hash, records nothing, and exits 0,
on a dry run too; --force deploys anyway.
A dry run prints no plan_hash and records no preview when:
- the host, connecting user, or health domain resolves through a masked reference;
- the checkout has uncommitted changes or untracked files and no
--force(never reported as already at this version, since the tree isn't what the live release holds).
A wetrun of either is refused.
A wetrun executes only the reviewed plan, as zap apply's does, but against the
deploy preview: its plan's hash must equal --expect-plan or the recorded
preview's, or it refuses before the preflight and any step, becomes the new
preview, and exits 2 (on an interactive terminal with no --expect-plan it asks
for the start of the plan_hash instead). A wetrun that passes withdraws the
preview before its first step, so one recorded preview covers one execution. zap status doesn't show the deploy preview. The rules are in deploy.md.
Flags:
| Flag | Meaning |
|---|---|
--expect-plan <hash> | bind to this hash instead of the recorded preview: execute only if the plan has exactly it. Wetrun only, and not single-use. |
--force | deploy an unchanged version, and version a dirty tree <hash>-dirty-<id>, named by its changes. Without it a wetrun of a dirty tree is refused. |
--skip-build | leave deploy.build out of the plan; ship the artifacts as they are |
--skip-preflight | skip the drift preflight. The machine-free gates and the env check still run. |
--skip-build, --skip-preflight, --force on a dirty tree, and --bootstrap each change the plan, and so its hash: a wetrun binds to a preview made with
the same flags.
Exit codes:
| Code | Meaning |
|---|---|
| 0 | dry run rendered, release deployed, or already deployed |
| 2 | refused: the plan was not the reviewed one, was declined at the prompt, or its preview was spent by another run first |
| 2 | the preflight found drift, or couldn't read every resource |
| 2 | a wetrun whose host, connecting user, or health domain resolves through a masked reference, or from a dirty checkout without --force |
| 2 | --expect-plan on a dry run; a zap.lock that doesn't parse |
| 1 | a step failed, including the release script finding another deploy of the app holding its deploy lock or having swapped since this plan was made (rollback may have restored the previous release; the exit stays 1); the recorded preview couldn't be withdrawn; zap.lock couldn't be opened |
zap detect
zap detect [--no-cache] [--bootstrap] [--fgro <p>] [--env-file <p>] [config]The detection half of plan, rendered raw: one line per resource with its
mark (ok, drift, absent, or ? for undetermined), its id and kind, and,
for drift, what was observed. For validating that a probe reads a real target
correctly. Over a host that resolves through a masked
reference it keeps no last-run report and ends with an UNRECORDED note.
| Code | Meaning |
|---|---|
| 0 | every resource was read and every reference and pin resolved; drift and absence still exit 0 |
| 1 | a resource marked ?, an unresolved reference, or a lock standing error |
zap status
zap status [--fgro <p>] [--env-file <p>] [config]Offline: evaluates the config, reads the last-run report for the host the target
resolves to (detection.md) and the on-disk zap.lock, and never touches the target. It says what the last run saw, never
what the target is now; the live signal is zap plan. A referenced host needs
its env (--env-file), as for a run. For an exact answer it needs the same env
as the run that wrote the report, secrets included: a secret it can't mask reads
as "config changed since", never as a false "unchanged".
$ zap status
target local (pkg: none, service: none)
last run on local: zap apply --wetrun, after its changes ran, at 2026-10-11T21:09:26Z — offline; `zap plan` reads the target live
a 0 exit means every declared resource read converged in the last run and its config is unchanged since — not that the target still is
converged notes directory
converged hello fileEach line is a resource's verdict in that run, with a note when:
| Note | Meaning |
|---|---|
config changed since; this verdict predates it | the resource's hash no longer matches: it was edited, an ordinary variable it references resolves differently, or its pin moved. When the target itself changed (its package or service manager, home, user), a note names it and every verdict predates it. |
depends on a secret; a changed value isn't visible offline | it references a secret |
not read (not in the last run) | declared, but not in the last run: added since, or there is none |
orphaned (read …, no longer declared) | in the last run, no longer declared; warn-only |
A preview's plan_hash is printed as the plan zap apply --wetrun binds to. The
lock standing and an UNVERIFIABLE banner, for the last run or for now, lead
the render. With no last run every resource is not read, so status exits 1
unless nothing is declared.
| Code | Meaning |
|---|---|
| 0 | every declared resource read converged in the last run and its config is unchanged since (a secret's value excepted); lock clean; nothing unverifiable |
| 2 | the last run read drift or absence |
| 1 | anything unestablished: not read, changed since, undetermined, unverifiable then or now, a lock error, no last run, or a host that keeps none (one behind a masked reference) |
zap config validate
zap config validate [--fgro <p>] [--env-file <p>] [config]Evaluates the config and runs the two gates, nothing else. Prints ok: N resource(s) and exits 0, or the gate's message and exits 2. The fast
check for CI and editors: no target, no network, no secrets needed, but a copy
of the pinned fgro build (lock.md).
zap lock
zap lock [--check] [--fgro <p>] [--env-file <p>] [config]The verbs over zap.lock, the ledger for the toolchain pin and the tracked remote files and build sources (lock.md).
zap lock pins the toolchain, the blake3 of the fgro binary it evaluates the
config with, re-fetches every tracked source, re-mints every entry, drops
orphans, and rewrites the file. All or nothing. It is the only command that
evaluates with a binary the lock doesn't pin, and the only one that moves the
toolchain pin (lock.md).
zap lock --check first holds the fgro binary to the toolchain pin: with a
binary that hashes to something else it reports the pin stale without
evaluating the config, so the entries go unchecked. Otherwise it classifies
each entry offline as current, missing, stale (the resource changed since the
pin), unverifiable (a variable the hash takes by value is unset), or orphaned.
Its 1 is a read-only verdict, like plan's held resources; apply, which
refuses to act on a stale or unverifiable entry, treats the same finding as an
error and exits 2, as does a deploy wetrun's preflight.
| Code | Meaning |
|---|---|
| 0 | zap lock: the lock was written. --check: every entry is current; orphans only warn. |
| 2 | no toolchain pin (--check); a tracked URL that references an unset or empty variable (zap lock, which then fetches nothing) |
| 1 | --check: a pin missing, stale, or unverifiable. Either: a fetch or another operational error (all commands) |
Both print a note: for each reference the lock addresses by name only, because
its name or the form of its value reads as a secret (lock.md).
zap update
zap update [--config <p>] [--fgro <p>] [--env-file <p>] [ids…]Re-fetches the named resources, or every tracked one, and rewrites each entry
whose pin advanced or whose recorded config hash is stale. Each advance is
written as it happens, so a later failure keeps the earlier ones. A tracked URL
that references an unset or empty variable is never fetched, and that entry
fails. The config is --config <path> here, since the positional arguments are
ids. A failed entry keeps the code its error has everywhere else: exit 1 if any
failure is operational (a fetch, a write), else 2 if any entry failed on an
input to fix (an unset reference in its URL or credential, a signature that
doesn't verify). It prints the same note: lines as zap lock.
zap version
Prints zap <version>.
Banners
What a pass couldn't establish, or why it didn't act, leads its report:
| Banner | Means | Exit |
|---|---|---|
UNDETERMINED | resources detection couldn't read, each with why, then the changes held behind them, each with what it waits on or would trigger; all held out of the plan | 1; 2 in a deploy's preflight |
UNVERIFIABLE | referenced variables that were unset or empty | 1; a wetrun refuses with 2 |
LOCK | a tracked pin that is missing, stale, or unverifiable | 1; apply and a deploy wetrun refuse with 2 |
UNREVIEWED | a wetrun's plan isn't the reviewed one; on a terminal, Zap asks | |
REFUSED | a wetrun that didn't execute; its plan becomes the preview | 2 (1 when a resource is held) |
DECLINED | the confirmation wasn't the plan's hash prefix | 2 |
RESIDUAL | resources that still don't read converged after a wetrun | 1 |
UNBINDABLE | the host, connecting user, or (deploy) health domain is behind a masked reference: no plan_hash, not a preview | |
UNRECORDED | the host is behind a masked reference: no last-run report kept |
Exit codes, all commands
The pattern across commands. The tables list 2 before 1 because 2 is still an established answer and 1 is not:
| Code | Family |
|---|---|
| 0 | established and fine: converged, deployed, nothing to do |
| 2 | established and needs a decision: changes pending (for the commands that judge them: apply, status), a refused or declined wetrun, preflight drift, a config error |
| 1 | unestablished or failed: a held or unverifiable resource, a failed change, a failed step, an operational error |
plan, detect, and a deploy dry run report rather than judge: pending changes
exit 0 there.
An error prints as error: and its message; a message of several lines is
indented under it, and any advice Zap adds follows at the left margin.
Errors raised before a report exists follow the same split.
Exit 2, a problem in your inputs:
fgro: none named; a path that isn't absolute; one that is missing, a link, a script or set-ID file, or doesn't hash to the toolchain pin; one that answers in an eval protocol or graph version thiszapdoesn't read; no toolchain pin; anfgropin cache under~/.zapthat isn't yours.zap.lock: one that doesn't parse or is newer than thiszapreads; a lock stale or unverifiable; a content-hash or signature mismatch.- The config: not found; named as its own lock would be (
*.lock); it or its directory writable by every user; no project; apackage.jsonon the way up that can't be read or isn't valid; no user the evaluation may run as (a project root owns, a project that is or holds that user's home, a directory on the way it can't search, a group or passwd entry it can't use); eval failure; a schema error. - The environment: an env-file error; a
ZAP_HOMEorHOMEunset where needed or not absolute; a state directory that isn't Zap's own; an unresolved reference on a wetrun. - A wetrun refused before its first change: a plan that names no one machine, a command that can't take a value its reference resolves to, or a reference that moved while the run was in progress.
- A
deploywetrun from a checkout with uncommitted changes or untracked files and no--force. - A usage error: an unknown flag, or
--expect-planwithout--wetrun.
Exit 1, a problem in the world: connectivity, a command that can't spawn, a file
operation, a fetch, a git error; an fgro that crashed, wrote output that isn't
its protocol, or couldn't be spawned; an fgro binary written while Zap hashed
it or before it ran, which a retry clears once nothing writes it.
JSON events
--json on a wetrun of apply or deploy streams one JSON object per line to
stdout, instead of the text report. A wetrun that ends before its first event (a
refusal, a preflight bail, an already-deployed release) leaves stdout empty and
prints its text report to stderr. Control and invisible characters in a value
are written as JSON \u escapes, so the stream decodes to the same values and
is safe to watch. Each event carries an event tag:
event | Fields | When |
|---|---|---|
run_started | target, wetrun, plan_hash, total | once, before any change |
change_started | resource_id, action (create / update), kind | a change is about to run |
change_completed | resource_id, output | it finished |
change_failed | resource_id, error, code | it failed; execution stops |
change_skipped | resource_id, reason | not run, because an earlier change failed |
step_started | step, name | a deploy step is about to run |
step_completed / step_failed / step_skipped | step, plus output / error / reason | the deploy step's outcome |
run_finished | summary (total / completed / failed / skipped counts) | once, at the end |
A step skipped after an earlier failure has no step_started, but a started
step's outcome can be step_skipped too: a readiness check the target doesn't
serve, or a rollback whose swap back finds nothing to do (another deploy holds
the app's lock, current no longer names the failed release, or names nothing, or the previous
release is gone). A rollback with nothing to return to is skipped without
starting.
code on a failure is a triage category: transient, permission, not_found, conflict, permanent, or execution_failed (permission includes sudo's own refusals). A file operation Zap makes itself is classified
by the error the system returned (a symbolic link or anything else in the way,
or a full disk, is conflict; a timeout is transient); a command, or ssh,
by its exit code and what it printed. Every value is masked: a resolved secret
never appears in an event.
A run that streamed events doesn't print its text report, and the report's warning: lines go with it: the stream carries a warning only where an event
holds it. A deploy's rollback is always one: rolled back, failed, or skipped,
with nothing to return to (a broken release left live) among the skips. The
others, such as a deploy's repairs of an unfinished one or a failed prune,
aren't in the stream.
Environment variables Zap reads
| Variable | Meaning |
|---|---|
ZAP_HOME | where Zap keeps operator-side state (default ~/.zap): the eval cache, the fgro pin cache, the content cache, last-run reports, deploy previews, SSH multiplexing sockets. An absolute path; empty counts as unset. See lock.md. |
ZAP_FGRO | the fgro binary that evaluates configs, by absolute path (an empty one is an error, not unset); never looked up on PATH. --fgro overrides it, and is the one that survives sudo. Name a copy you update deliberately, not Gro's build output (install.md). |
ZAP_MINISIGN | the minisign binary a build source's verify runs, by absolute path (an empty or relative one fails the verification, saying so); unset, the first minisign in an absolute directory on PATH. Either is refused when it, or a directory on the way to it, is writable by a group or others or owned by anyone but root and you, or it sits in node_modules (resources.md) |
ZAP_EVAL_CACHE | shadow (default: eval fresh, compare against the cache, report a mismatch) or skip (serve a cache hit whose files are unchanged without running fgro; a change the evaluation's record doesn't name isn't seen: a symbolic link, in node_modules or the project, pointed at other files, or a directory turned world-writable) |
ZAP_EVAL_CACHE_LOG | set to print eval-cache hit/miss observations |
Nothing that shapes a plan comes from Zap's own environment. Values a config
needs come from references resolved from the operator environment or --env-file.
Resources #
A Zap config (zap.ts) declares resources: typed descriptions of desired
machine state. Zap detects the live state, compares it to the declared
resources, and applies the smallest set of changes that converges them. Each
resource has a stable id, an optional name, an optional depends_on list,
and a kind, set by the builder method you call (it becomes the type in the JSON
the config evaluates to).
export default (zap) => {
zap.target({local: true});
zap.pkg({id: 'ripgrep', from: {apt: 'ripgrep'}});
zap.build({
id: 'ghostty',
source: {type: 'remote', url: 'https://example.com/ghostty.tar.gz', extract: {type: 'tar.gz', strip_components: 1}},
run: 'zig build -p $HOME/.local -Doptimize=ReleaseFast',
output: '$HOME/.local/bin/ghostty',
});
};Resource types
| Builder method | Type | What it declares |
|---|---|---|
zap.pkg | package | an OS package, through the target's package manager |
zap.apt_repo | apt_repo | an apt source list and its signing key |
zap.script | script | a command gated by an idempotency check |
zap.installer | installer | a script fetched with curl and run by a shell, gated by a check |
zap.user | user | a Unix user: shell, home mode, SSH keys, sudo |
zap.directory | directory | a directory at path, with mode and owner |
zap.file | file | a file with inline, template, source, or remote content |
zap.link | link | a symlink from path to target |
zap.service | service | a system service: enabled, started, stopped |
zap.database | database | a PostgreSQL database and its role |
zap.swap | swap | a swap file in /etc/fstab |
zap.firewall | firewall | default policies and allow rules |
zap.verify | verify | detection-time assertions, with no execution of its own |
zap.pipeline | pipeline | an ordered imperative workflow of steps |
zap.build | build | a verified source archive, built and installed |
Each section below gives the resource's purpose, its fields, and any structure
specific to the type. In path fields, references resolve against the
operator environment, and ~ and $HOME resolve against the target's home.
Both happen when Zap reads or changes the machine, and the plan holds both as
written (env.md).
One resource per thing
Two resources that manage one thing would undo each other on every apply, so a config that declares them doesn't validate; the message names both ids and the thing. What a resource manages:
| Type | Manages |
|---|---|
file, directory, link, swap | its path |
build | its output and the record beside it |
apt_repo | its key file and its list file |
user | the account, its ~/.ssh directory and authorized_keys when ssh_keys is set, and its sudoers file when sudo is set |
service | the service |
database | the database (several databases may share a role) |
firewall | the machine's firewall (a config declares at most one), and under nftables /etc/nftables.nft |
A package, a script, an installer, a pipeline, and a verify manage
nothing exclusive, so the same package may be declared twice. Two resources
at one path conflict even when they declare the same thing, so a directory two
recipes both need is declared once. Paths are
compared as written, after //, /./, and a trailing / are dropped and $HOME is read as ~; two spellings that only meet on the machine (~/x and /home/ops/x, a .. component, nginx and nginx.service) aren't caught.
URLs
A URL in a config is https:// with a host, and holds no user:password@ (a credential goes in auth, where it is a reference). Any other scheme is
refused, http:// and file:// included, when the config is read and again on
the resolved value when the URL holds a reference. For the URLs Zap itself
fetches (a remote file's, a build source's and its signature's, an apt_repo's key_url), http:// is accepted for a loopback host
(localhost, 127.0.0.0/8, [::1]), which is what a local mirror or a test
uses. A redirect is followed only to another https:// URL; a fetch that has
stayed on loopback may be redirected on loopback. The URL the target fetches
(installer.url) is https:// only.
Account names
username, groups, and every owner and group field take an account name, such as 'deploy', not a numeric uid or gid. Zap compares ownership
against the name from stat, so a numeric value is rejected when the config is
read.
The name must be a portable Unix account name: a lowercase letter or _, then
lowercase letters, digits, _, or -, optionally ending in $. That is the useradd default on Debian and Alpine. Anything else (an uppercase letter, a
space, /, a comma, a backslash) is rejected there too. The name is
interpolated into awk, usermod -aG, a sudoers drop-in, and /home/<name> paths, where a stray character would break out of the form.
package
An OS package, installed through the target's package manager: apt (Debian/Ubuntu) or apk (Alpine). The other managers (pacman, brew) parse
but have no backend, so their packages detect as undetermined.
zap.pkg({id: 'ripgrep', from: {apt: 'ripgrep'}});from(required): a map from package manager to package name, such as{apt: 'ripgrep'}. The target'spackage_managerselects the entry. A name takes no reference; see Package names.check(optional): a custom check, either a path (tested for existence) or a command (exit 0). It defaults to the manager's own query. A check beginning/,~, or$HOMEis a path, and its references resolve; anything else is a command, run as written, and takes no reference. With nocheck, a package with no entry for the target'spackage_manager, or a target that declares none, holds (undetermined).
Installs are batched, after an automatic cache-update step. A package that waits
on another resource that waits on a package installs in a later batch
(batch-install-<manager>-2, …), after that resource. Detection is by
existence through the manager's query: a package held back, or installed for any
architecture, is installed. A query that fails (the database unreadable, the
tool missing) holds the package as undetermined rather than reading it missing.
Package names
A name is one word in the manager's syntax: a letter or a digit, then letters,
digits, and + . _ - = ~ : / @, not ending in -. Zap writes it into the
manager's command line unquoted, so a space or a shell character is refused.
The form decides what reads as installed:
| Form | Manager | Installed when |
|---|---|---|
name | apt, apk | the query finds it, at any version |
name=version | apt | it is at exactly that version, epoch and revision included. apt upgrades to it and refuses to downgrade to it, which fails the step. |
name/suite | apt | name is installed; the suite chooses where a first install comes from |
name:arch | apt | it is installed for that architecture; a package built for every architecture (all) counts as the target's own |
name=version, name~version | apk | the installed version matches, as apk compares them |
name@tag | apk | name is installed; the tag chooses where a first install comes from |
A version and a suite together name no package apt knows. Refused, because the query would never find the package installed or the form isn't one word:
- apt: an empty version (
name=) or architecture (name:);name:anyandname:native, which apt resolves to an architecture and dpkg records no package under;name:all, which apt reads as the native architecture while dpkg recordsallonly for a package built for every one (name the architecture, or none); an architecture that isn't one word of lowercase letters, digits, and-. - apk:
name>=1.2,!name,.name; a version operator with nothing after it (name=,name~,name==,name@tag=), which apk's query reads installed whether or not it is andapk addrefuses.
A trailing + is part of the name, since Debian has packages named so
(memtest86+). apt's own name+ install marker on a package with no + installs it but never reads installed, so write the name without it.
apt_repo
An apt repository: a GPG signing key plus a sources-list entry. Zap installs
the key, writes the .list file, and injects signed-by=<key_path> into the
source line automatically, so the key and the repo can't drift apart.
zap.apt_repo({
id: 'pgdg',
key_url: 'https://www.postgresql.org/media/keys/ACCC4CF8.asc',
source: 'deb https://apt.postgresql.org/pub/repos/apt bookworm-pgdg main',
});key_url(required): the URL Zap fetches the GPG signing key from (URLs). It may carry a reference.key_blake3(optional): the key's pin, the blake3 of its bytes, 64 lowercase hex. Without it the pin is tracked inzap.lock(lock.md).source(required): thedebordeb-srcsource line.signed-by=is injected fromkey_path.key_path(optional): where the key is stored. It defaults to/usr/share/keyrings/<id>.asc.list_file(optional): the.listfile path. It defaults to/etc/apt/sources.list.d/<id>.list. The list is installed whole, mode644.
key_path and list_file take no reference; key_url may carry one. Each of
the two paths is absolute and holds no whitespace and none of ", $, a
backtick, or \.
source is written through a shell with /etc/os-release sourced, so it can
reference the distro's variables for portability. For example, deb … $VERSION_CODENAME main resolves to bookworm, noble, and so on, on
the target. key_path is written into that line, so the shell reads it too. list_file is passed literally; key_url is fetched by Zap on the operator's
machine and never reaches the target. The detector resolves source the same
way, so a templated line still converges. Because the shell reads the line, source takes no reference.
The key is pinned content, like a remote file's. Zap fetches it on the
operator's machine, checks the bytes against the pin, and installs those bytes
whole at key_path, mode 644, owned by root:root: staged beside the key,
checked against their SHA-512, and renamed over it, so the live key is only
ever replaced by the pinned bytes. A fetch whose bytes aren't the pin's fails
the change before anything on the target is touched, naming both digests:
content hash mismatch for https://www.postgresql.org/media/keys/ACCC4CF8.asc: declared <pin>, fetched <blake3 of what was served>Detection reads the key and compares its blake3 with the pin: a key that
isn't the pinned bytes drifts (signing key differs from its pin), and the
apply installs the pinned one. A rotated upstream key is not drift until the
pin moves: zap update <id> fetches the key again and advances the pin, which
shows in the lock's diff. A key with no pin yet is held (undetermined) by the
read-only commands, a dry apply mints its pin, and a wetrun records it in zap.lock before it runs, as for any tracked content. A key_path that is a
symbolic link, a hard link, or not a regular file, or a key over 1 MiB, is held
too: its bytes aren't compared.
script
An imperative command guarded by an idempotency check. Zap runs check first.
An exit code of 0 means the script is already applied and run is skipped.
Otherwise run executes.
zap.script({
id: 'enable-lingering',
check: 'loginctl show-user $USER | grep -q Linger=yes',
run: 'sudo loginctl enable-linger $USER',
});check(required): a command whose exit 0 means "already applied", which skipsrun.run(required): the command run whencheckfails.cwd(optional): the working directory forcheckandrun. An absolute path, taken as written: a reference is rejected when the config is read, and a~or a relative path by the structural gate.env(optional): extra environment for the whole ofcheckandrun. Values resolve references and can hold any byte but NUL (and the two characters Zap marks a reference with, U+FDD0 and U+FDD1), soenvis how a script takes a value with a'in it, or a secret. A name can't begin with__zap_.
The check carries the whole idempotency contract. Make it precise, or the
script runs on every apply.
cwd and env apply to both commands, the same way on a local target and over
SSH: Zap enters cwd, exports env for the whole command (every command of a
compound sees it), then runs your text on a line of its own. The plan shows
them, and the check, above the command:
[+] migrate (script)
cwd: /srv/app
env: DATABASE_URL={env:DATABASE_URL}
env: MODE=prod
check: test -f .migrated
> ./migrate.shA check that can't enter cwd reads as not applied, like any failing check, so
a script whose cwd another resource creates converges in one apply. A run that can't enter it fails the change with nothing run. A sudo inside either
command resets the environment by default, so env doesn't cross it.
installer
Software installed by running a downloaded script, the curl … | sh pattern,
gated by a check so it runs only once. Zap fetches the script whole first and
hands it to the shell only if the fetch succeeded and the script isn't empty, so
a failed download fails the step and nothing runs.
zap.installer({
id: 'deno',
url: 'https://deno.land/install.sh',
check: '~/.deno/bin/deno',
});url(required): the install script'shttps://URL (URLs), piped intoshell.check(required): a path, absolute or beginning with~or$HOME, tested for existence to confirm installation.shell(optional): the command the script is piped into (defaultsh). It is one line, shown in the step as written and read by the target's shell like ascript'srun, sobash -s -- --yespasses arguments.
An installer executes remote code on the target, so prefer a pinned, trusted
URL. For content that should be verified and pinned, a build or a file with remote content is the stronger choice.
user
A Unix user account. Zap creates the account if it doesn't exist, with useradd -m -d /home/<username>, so the home is /home/<username> whatever the
distro's default. Zap then detects and reconciles
each managed attribute independently. An attribute left unset is unmanaged: Zap
neither sets nor reverts it.
Zap manages a user's home as /home/<username>: the paths home_mode and ssh_keys act on are derived from the name, not read from the machine. The
cases where that would be wrong fail loudly instead:
- A
userwithusername: 'root', whose home is/root, is rejected by the structural gate before any run, whatever it declares. Manage root's files withfileanddirectoryresources. - An existing account whose home in
/etc/passwdis anything else, or that has none there, is held (undetermined) when the user declareshome_modeorssh_keys, and the reason names the home it found:deploy's home in /etc/passwd is /srv/deploy, not /home/deploy: zap manages a user's home as /home/<name>, so its `home_mode` and `ssh_keys` would miss the account's ownAn account that doesn't exist yet has no
/etc/passwdhome to hold it: Zap creates it. - What is at
/home/<username>is read too, without following a link, whether the account exists or not.useraddleaves a directory already there as it was, owned by root typically, and copies no skeleton files into it:- when Zap creates the account, the creation gives the directory
itself, not what it holds, to the user and its login group after
useradd:sudo chown -h -- 'deploy:' '/home/deploy', a no-op whenuseraddmade the directory; - for an existing account, a directory the user owns or root owns is as
declared and keeps its owner, so a home sshd's
ChrootDirectoryneeds root to own stays root's; - a directory owned by any other uid, a live account's or one no account has, holds the user, existing or not;
- a symbolic link at
/home/<username>holds the user and is not followed: Zap acts through no link it didn't make, so a layout that links the home elsewhere declares neitherhome_modenorssh_keys; anything else that isn't a directory holds too; - a
/homethat is missing, or that the connecting user can't search, holds the user, naming/home, rather than reading as nothing there.
/home/deploy is owned by uid 1002 (deploy's uid is 1001); chown it to deploy, or remove it: zap manages a user's home as the directory /home/deploy, the user's own or root'sA user that declares neither
home_modenorssh_keysisn't read for its home. - when Zap creates the account, the creation gives the directory
itself, not what it holds, to the user and its login group after
zap.user({
id: 'deploy',
username: 'deploy',
shell: '/bin/bash',
ssh_keys: {copy_from: 'root'},
sudo: 'nopasswd',
});username(required): the Unix username to manage. It must be a portable account name; see Account names.groupsentries follow the same rule.shell(optional): the login shell, such as/bin/bash. The system default applies when unset. It takes no reference.home_mode(optional): the octal mode for the home directory, such as711.ssh_keys(optional): the SSHauthorized_keys, as either{copy_from: '<user>'}or{keys: ['ssh-ed25519 …', …]}, never both: an object with both is rejected before any run. Akeyslist needs at least one key, and no entry may be blank. See SSH keys.sudo(optional): the user's sudo grant. See Sudo.groups(optional): supplementary groups the user must belong to, such as['docker', 'postgres']. It is additive: listed groups are added (usermod -aG) and groups not listed are left untouched. The primary and system groups are never stripped. Names only, likeowner.
SSH keys
ssh_keys is authoritative. Zap writes authorized_keys to exactly the
declared set and detects drift by comparing the file's content. A key added out
of band is removed on the next apply, and a rotated or removed key is applied
again.
A key is installed as written and takes no reference; one is rejected
before any run. An entry is one key on one line: an entry that ends in a line
break doesn't validate. A config reads no file but its imports, so for keys that
differ per environment, have the config import them from a module in the project
(export const OPS_KEYS = ['ssh-ed25519 AAAA… ops'];).
For copy_from, the source user's authorized_keys is the desired content. If
it can't be read, the user is applied again and does not read converged.
Zap gives the account its ~/.ssh (mode 700, owned by the user; a symlink
there is refused, not followed) and then writes authorized_keys as that
user, mode 600, so nothing the user plants in its own .ssh can redirect
the write anywhere the user couldn't already write. A copy_from source is read
as the source user at apply. Other files in .ssh are left as they are.
Setting ssh_keys also normalizes the account's /etc/shadow password field to *, which means no password and not locked. This is authoritative too: any
other value drifts and converges back to *. That covers:
- a
!lock.useraddleaves a passwordless account locked, and Alpine's sshd refuses pubkey login to a locked account ("account is locked"). - an empty field, which allows passwordless login and is a security hole;
- a set password.
Sudo
'nopasswd'or'password'grants sudo for all commands.{mode: 'nopasswd' | 'password', commands: ['<sudoers spec>', …]}restricts the grant to the listed commands. A spec is written into the file as declared and takes no reference. The list can't be empty, and a spec can't be blank or hold a control character (a tab counts): each is rejected when the config is read. For a grant of every command, write the mode alone. A spec is sudoers text, so a,in one ends a command and begins another.falseremoves the user's sudoers file.trueis rejected at parse. It grants nothing, and would otherwise read as a removal.
The sudoers file is installed at mode 440 only after visudo -c accepts it;
a file visudo rejects is never installed, and the old one stays.
directory
A directory at path, with optional managed permissions.
zap.directory({id: 'app-dir', path: '/opt/app', mode: '755'});path(required): the directory path, absolute or under~. It may carry a reference, and the value it resolves to is held to the same rule.mode(optional): the octal mode, such as'755',7777at most.owner(optional): the owner.group(optional): the group.
Zap makes the directory and any missing parents. Each one it makes, the
directory itself included, takes the owner and group of the nearest directory
that was already there, and the mode a plain mkdir gives; then the declared
mode, owner, and group are set. So an owner or group left unset is inherited
from above, and an existing directory keeps its own. Anywhere below a directory
with its sticky bit set or that every account may write (/tmp, /var/tmp, /dev/shm), however deep, on the path as written or where its links lead,
nothing is inherited: what Zap makes there is the writer's. On a local target,
directories that take another account's owner, or a group you aren't in, are
made through sudo.
With a managed mode, owner, or group, a symlink at path reads as drift and
fails the change: Zap sets a directory's attributes on the directory itself and
never through a link. Use link for a symlink.
file
A file with managed content and optional permissions. Content comes from one of
these sources, selected by content.type: inline, template, source, or remote.
The file's parent directory is created automatically if missing, like mkdir -p: each directory made takes the owner and group of the nearest one
that was already there (the writer's, below a directory anyone may write, as in directory), and the mode a plain mkdir gives; an existing one
is left as it is. A separate directory resource is only needed when the directory
itself wants a managed mode or owner.
A file is a regular file, written whole: Zap stages the content under a new
name beside it (.zap.XXXXXX, mode 600 until a declared mode is set), checks
over SSH or sudo that the bytes that arrived are the bytes it sent, sets the declared owner and mode on that staged file, and
renames it into place. So the file never exists at its path with partial
content or the wrong mode, a mode or owner drift rewrites it, and a symlink at
the path is replaced by the file, not followed (a symlink to a directory fails
the change on Alpine; with a managed mode, owner, or group, a symlink there
reads as drift). Use link for a symlink.
A mode, owner, or group the file leaves unset is kept: a rewrite gives the new
content the owner and group of the file it replaces, and its mode without any
set-id or sticky bit. A new file takes its directory's owner and group, and the
mode a plain create gives (0666 less the writer's umask). A symlink at the
path, or a file with a second name (a hard link), keeps nothing; the file that
replaces it is the writer's. Nothing is kept anywhere below a directory with its
sticky bit set or that every account may write (/tmp, /var/tmp, /dev/shm),
however deep, on the path as written or where its links lead: anyone could have
put what is there, or made a directory or a link on the way, so the file is the
writer's.
A mode is never kept from a file with an extended POSIX access list (ACL): its
mode bits show the list's mask rather than its group's, and the rewrite carries
no list, so a kept mode would hand the group what the list gave one account. A file that leaves mode unset over such a file fails the change, with the file
as it was, asking for a declared mode. Zap reads the list with getfacl, else
from the + GNU ls -l prints after the mode; a target with neither, such as
stock Alpine (BusyBox ls, no acl package), can't show one, and there the mode
is kept. On a local target,
keeping an owner or group you can't give, such as another account's file or a
new file in a directory root owns, goes through sudo.
Declare mode, owner, and group on any file whose content the directory's
owner or group must not read. Without them, a file written into a directory
another account can write can end up readable by that account: it takes that
account's directory's owner and group, or the owner, group, and mode of the file
that account planted at the path. A declared owner and group don't replace a
planted file's mode, so without a mode the content can land readable by
everyone (security.md).
zap.file({
id: 'motd',
path: '/etc/motd',
content: {type: 'inline', content: 'Managed by zap.\n'},
mode: '644',
});path(required): the target path. It may carry a reference and~.content(required): the content source. Seecontent.mode(optional): the octal mode, such as'644'.owner(optional): the owner, as a user name and not a numeric uid. See Account names.group(optional): the group, as a group name and not a numeric gid.triggers(optional): ids ofserviceresources notified when this file changes; naming any other kind doesn't validate. This is the push side of the trigger model; seeservice.validate(optional): a command that must exit 0 after the write, such assudo nginx -t(exported asNGINX_VALIDATEfromrecipe_nginx.ts). See Validated writes.
Validated writes
With validate set, apply snapshots the file (its bytes, mode, and owner, read
through one open descriptor and held in memory: as root over SSH, and locally
as you, escalating when refused), writes, then runs the
command against the file in place. A symlink at the path, anything there that
isn't a regular file, and a file over 16 MiB can't be snapshotted, so the change
fails before the write. If the check fails, the snapshot goes back as a regular
file with the owner and group the old content had, and its mode without any
set-id or sticky bit the declared mode doesn't carry; a file that didn't
exist is removed; and the change fails. So any service the file triggers is never reloaded onto the
bad config. A write that fails before installing leaves the file as it was, and
restores nothing.
Checking in place is what lets it cover a file some larger config includes: nginx -t can't check a site file on its own.
zap.file({
id: 'nginx-config',
path: '/etc/nginx/sites-available/example.com',
content: {type: 'inline', content: site},
validate: NGINX_VALIDATE,
});What it doesn't cover:
- One file at a time. Each file is checked against whatever else is on disk. An edit that has to land in two files at once (renaming a zone one file defines and another uses) fails the first file's check and rolls it back. Stage the edit across applies: add the new name, switch the user, drop the old.
- Not yet active. The check only sees what the parent config loads. An nginx
site in
sites-available/isn't checked until itssites-enabled/link exists, so on a fresh host the first real check is the reload. - The write-to-restore window. The rejected content is on disk for one command's round trip. A run killed inside it leaves that content in place, and detection reads it as converged, since the bytes match what was declared.
The last two, plus hand edits on the host, are what a verify running the same command closes. It is checked again on every plan. A config the
running service couldn't reload onto then shows up as a pending change and does
not read as converged, and zap deploy's preflight refuses to ship over it:
zap.verify({
id: 'nginx-config-valid',
...nginx_config_verify(), // {assertions: [{type: 'command', run: NGINX_VALIDATE}]}
depends_on: ['nginx-service'],
});validate is apply-time behavior, not desired state: detection doesn't read
it, and changing it alone plans no change. It is still part of the declaration:
the last-run report's hash covers it, and so does plan_hash whenever the file
has a change in the plan. The lock's config_hash leaves it out. It is for text config
(inline, template, source), and is rejected on remote content and when
blank.
content
The content source is discriminated on type.
inline: literal content, written verbatim.
type(required):'inline'.content(required): the file's content, installed as declared. It takes no reference.
template: a {{key}} template with per-variable substitution.
type(required):'template'.template(required): the template body, with{{key}}placeholders. A placeholder is a variable's name between the braces and nothing else; braces around anything that isn't a name ({{ .Values.port }}) are text.vars(optional): the values substituted for each{{key}}, after a reference in a string value resolves. A value that isn't a string is substituted as its compact JSON text and takes no reference at any depth; pass the value as a string var of its own. A reference can't stand for the wholevarsmap. A value with no kind is substituted as text, so it has to be safe where it lands: in a file a shell or systemd'sEnvironmentFile=reads, a value holding a'ends its quoting.kinds(optional): the kind of value a variable must render to, by its name. Each key names a variable invars. A variable not named here declares no kind: a reference in its value is still held to thetextrule, and a value written out is substituted unchecked.
Placeholders and variables match one for one: a {{name}} whose name is
letters, digits, and _ (not starting with a digit) and that has no variable of
that name doesn't validate, and neither does a variable no placeholder names, so
a misspelled name is caught in either direction. The body is substituted in one
pass and a value is never read again, so a value may hold {{…}} as text; that
is also how a literal {{name}} is written.
A reference's value can't hold a line break (CR or LF) or a NUL: the files a template makes are read a line at a time, and a line break in a value would add a line of its own (another variable in an environment file, another directive in a config). Detection holds the file and names the variable, and a wetrun is refused before anything changes.
Within its line, what a value may hold is the file's grammar: a ; or a } in an nginx server_name {{domain}} ends the directive or the block. A kind
holds a variable's whole rendered value to a form whose alphabet has none of
that:
| Kind | A value is |
|---|---|
text | any one line: no line break, NUL, or reference delimiter; every kind keeps this rule, and a reference's value is held to it with no kind declared |
hostname | dot-separated labels of ASCII letters, digits, and -, each 1 to 63 bytes, no leading or trailing -, at most 253 bytes; no trailing dot, wildcard, or _ |
port | a decimal number from 1 to 65535, with no sign or leading zero |
path | an absolute path whose names are ASCII letters, digits, ., _, -, +, and @, with no empty, ., or .. name |
email | a local part of ASCII letters, digits, ., _, %, +, and - (no leading, trailing, or doubled .), an @, and a hostname |
env_value | a value inside single quotes in an env file: text with no ' |
zap.file({
id: 'nginx-site',
path: '/etc/nginx/sites-available/app',
content: {
type: 'template',
template: site,
vars: {domain: zap.env('DEPLOY_DOMAIN'), port: 4040},
kinds: {domain: 'hostname', port: 'port'},
},
});A value written out is checked when the config is read. One carrying a reference is checked once it resolves: detection holds the file, and a wetrun is refused before anything changes, naming the variable and its kind, never the value. The nginx site and env recipes return their templates whole, with the kinds of the variables they place (recipes.md).
source: content copied from a file already on the target at apply time.
type(required):'source'.path(required): a path on the target, absolute or under~. It may carry a reference.
The source is read as root over SSH, byte for byte, so a binary file copies whole. It must be a regular file of its own at that path: a symbolic link there is never read through, and neither is a directory, a device, or a fifo. Detection holds the resource on any of them, and apply refuses them with nothing written. The bytes are held in memory while they are written, so a source larger than 16 MiB is refused at apply.
remote: content fetched from a URL, content-addressed and verified the
same way a build source is.
type(required):'remote'.url(required): the fetch location (URLs). It may carry a reference, resolved when Zap fetches; how the lock addresses it is in lock.md.extract(optional): how the bytes are materialized.{type: 'none'}is the default;{type: 'tar.xz' | 'tar.gz', strip_components?: N}extracts an archive.strip_componentslives inside the tar form (besidenoneit is rejected), and strips leading path components:1flattens a tarball's single top directory.auth(optional): a bearer credential for a private byte host, resolved at the IO boundary and never hashed. See Remote auth.blake3(optional): a vendored content pin. Present, the file pins itself; absent, it tracks throughzap.lock. See lock.md.
How a remote file is detected. An unextracted file by digest against the
pinned bytes, an extracted one through its .zap_remote_hash sentinel;
detection never fetches. See detection.md.
Placement and ownership. Placement and extraction escalate as needed: a none placement or an archive extract into a root-owned destination (such as /opt/zig) runs under sudo. When extract produces a directory tree, a
declared mode, owner, or group applies to the top-level destination
only (a symlink there holds the resource, and nothing is extracted through it).
The extracted contents below it belong to the extracting user, which is root
when the extract escalated, whatever owners the archive records, and carry no
set-id bit on a file and no group or world write bit. The archive is extracted
into a private directory beside the destination, which then takes the
destination's name; a failed extraction leaves the destination as it was. A new
pin replaces the old tree whole, and the new one gets the owner, group, and
mode the old one had (without a set-id or sticky bit) before it takes the name,
so what the resource leaves unset there is kept, and a failure to set them
leaves the old tree in place. Below a directory with its sticky bit set or that
every account may write, nothing is kept, as for a file: the new tree
is the extracting user's, at the mode mkdir gives it. A replaced tree that
has a POSIX access list (ACL), a default one included, fails the change with the
old tree in place, whatever mode declares; remove the list (setfacl -b) to
let Zap replace it. The archive never sets the destination's mode, whatever it
records for its top directory: a new destination keeps what mkdir gives it. Zap replaces only a tree it extracted (one holding its sentinel) or an
empty directory; a destination holding anything else holds the resource
(undetermined), and nothing is extracted into it. If the whole tree needs a
specific owner, apply it with a script running a recursive chown.
The swap needs room for the old and the new tree at once, and the archive sits
on the target three times while it extracts: the upload in a private directory
under /tmp, the copy the extraction checks and reads, on the destination's
parent filesystem, and the tree it extracts. A run that is killed
can leave a mode 0700 .<name>.zap-XXXXXX directory of the extracting user's
(root's, when it escalated) beside the destination; killed between the two
renames, it holds the old tree. One is also left, empty, where an account that
can write the parent renamed it. The staging name is 12 bytes longer than the
destination's, so a destination name within 12 bytes of the filesystem's NAME_MAX can't be extracted.
Remote auth
For a private byte host, attach a bearer credential. Its token is typically a reference, resolved on the operator side at fetch time. It is never written to the content hash, and an empty resolution is an error, not a bogus request.
auth: {scheme: 'bearer', token: zap.env('API_TOKEN')}link
A symbolic link at path pointing to target.
zap.link({id: 'vim', path: '~/.local/bin/vim', target: '/usr/bin/nvim'});path(required): the symlink path. It may carry a reference and~.target(required): what the link points to, resolved the same way.
A regular file sitting at path reads as absent and is replaced by the link. A
directory there also reads absent, but the step fails rather than putting the
link inside it: move the directory, or point the resource at another path.
service
A system service managed through the target's service manager (systemd or OpenRC). It controls whether the service is enabled at boot and whether it
is running, and it can restart or reload in response to other resources
changing. That is the pull side of the trigger model; file's triggers is the push side.
zap.service({id: 'nginx', service: 'nginx', running: true, reload_on: ['nginx-conf']});service(required): the service name, as the target's service manager knows it. It takes no reference, and is one word: a letter or a digit, then letters, digits, and_ . @ : -(nginx,getty@tty1.service). Zap writes it into the manager's command line unquoted, so a systemd name escaped with a backslash isn't accepted.enabled(optional): whether the service is enabled at boot (defaulttrue).running(optional): whether it should be running now. Omit it to leave runtime state unmanaged. On OpenRC, starting a crashed service (its daemon died while it was marked started) first resets it withrc-service <svc> zap, since a plain start of a crashed service does nothing. OpenRC reads a service as crashed when the process its pidfile names isn't running, so a daemon still running under a stale pidfile gets a second copy beside it.restart_on(optional): resource ids whose change triggers a restart of this service:systemctl try-restarton systemd,rc-service --ifstarted <svc> restarton OpenRC.reload_on(optional): resource ids whose change triggers a reload of this service. That istry-reload-or-restarton systemd andrc-service --ifstarted <svc> reloadon OpenRC, where a service that can't reload fails the step.
A trigger acts only on a running service. A service that isn't running is left
as it is, and starts on the new state when something starts it, so a unit file
written on a first deploy restarts nothing before its binary is there. A service
converged but for its trigger runs the trigger alone (after systemd's daemon-reload), never its enable or start again. A trigger on a service
declared running: false (its restart_on / reload_on, or a file's triggers naming it) is a structural error: it could never act.
The target must declare a service_manager. The structural gate rejects, in
every command, a config with service intent on a target that declares none,
and doesn't guess systemctl on a host that may run OpenRC. Service intent is any of:
- a
serviceresource; - a
filewithtriggers; deploy.service.
A declared manager with no backend (launchd) parses. Its services detect as undetermined, and their verbs plan as notes, not commands.
Pending triggers
A trigger fires when one of its sources changes in the current plan. On its own, a failed reload would be lost: the next plan reads the source as converged (its bytes match) and the service as running (on the old config).
So Zap keeps a record on the target. Before the first source of a triggered
service runs, apply writes a marker to /var/lib/zap/pending/<service> holding
the owed action. It removes the marker only after the service's own change
completes. If the reload fails, or the run dies anywhere in between, the marker
stays, and the next plan fires the action again:
[~] nginx-service (service)
(pending reload from an earlier apply)
> sudo systemctl daemon-reload
> sudo systemctl try-reload-or-restart nginxA pending restart outranks a reload fired this run. The markers are read live,
as root, on every plan for a config that declares a trigger, and a marker for a
service the config doesn't manage is reported as a warning. A marker directory
that can't be read fails the plan rather than reading as owing nothing. Zap
gives /var/lib/zap and pending/ mode 755 and each marker 644, whatever
the umask. The service's change clears its marker whether or not the service was
running: a stopped service owes nothing, since it starts on the new state.
Zap keeps its state in /var/lib/zap, and refuses the declarations that would
manage it. A structural error is any resource whose path is at or under it as
written (. and .. taken as written), a home-rooted path with a .. component, a link whose target is /var/lib/zap, under it, or a directory
above it (/, /var, /var/lib, so no path below the link leads there), a link placed at one of those directories above it (which it would replace),
and a deploy.path there. A path or link target that reaches it only once a
reference or ~ resolves holds the resource in detection and is refused by the
wetrun. What isn't a declared path isn't checked: the commands a script, pipeline, installer, or build runs can write anywhere, and a symbolic
link already on the target that leads into /var/lib/zap is followed like any
other directory link.
The record is written first, so the guarantee is at-least-once. A run that fails after the marker but before any source changed fires one reload or restart it didn't strictly need. It never drops one it did.
A held service, whether detection can't read it or it is held behind another, gets no marker, so each source whose change would trigger it is held too, with the reason "would restart (or reload) the service, which is held". Running the source alone would owe a restart nothing records. The next plan that can run the service plans both.
database
A PostgreSQL database and its owning role. Zap creates the role and database
idempotently, sets the database's owner back to the role when it has drifted, and manages the declared pg_hba.conf auth rules.
It never restarts PostgreSQL, so the target needs no service_manager for it.
zap.database({id: 'app-db', engine: 'postgresql', database_name: 'app', username: 'app'});engine(required):'postgresql'.database_name(required): the database to create.username(required): the role to create, which owns the database.auth(optional): thepg_hba.confrules. See Database auth.
Both names, and each auth.hosts entry, are written into a pg_hba.conf line
as they are, so none may hold whitespace, #, ", ,, or a control character. A name doesn't begin
with +, @, /, or -, a database isn't named with a word pg_hba.conf reads as a keyword in that column (all, sameuser, samerole, samegroup, replication), and a role isn't named all. The rule holds for the value a
reference resolves to as well: one that breaks it holds the resource.
Both identifiers are taken literally and never shell-expanded. They accept
references, resolved on the operator's machine. A dev config that
wants "the role matching the OS user" on every machine declares username: zap.env('USER'). A shell-style username: '$USER' would never resolve.
Database auth
hosts(required inauth): the client addresses a rule admits, each an IP address with its prefix length ('127.0.0.1/32','::1/128'), at least one. A keyword (all,samehost,samenet) or a host name is refused, as written and as a reference resolves. Adatabasewith noauthwrites no rule.method(optional): thepg_hbaauth method of those rules (default'scram-sha-256'). A rule Zap writes is a plain TCPhostline with no options, so a method that can't work on one is refused:'trust', since a trusted TCP rule lets any account on the machine, any service among them, connect as the role with no credential;'peer', which PostgreSQL accepts only on a Unix-socket (local) line, the route a database with noauthalready takes;'cert', accepted only on ahostsslline;'ldap'and'radius', which need options Zap doesn't write; and'sspi'and'bsd', which PostgreSQL doesn't build on Linux.
With no auth, the role is reached the way the stock pg_hba.conf allows a
local account: over the Unix socket, by the OS account of the same name
(local all all peer on Debian, and on Alpine once alpine_postgres_setup has
initialized the cluster). A service running as that account
connects with postgres://<role>@/<database>?host=/run/postgresql and needs no
password.
Each rule is a host line in the pg_hba.conf the server reports. A missing
rule is inserted once, above the first host-type record (indented ones
included), or appended when the file has none, so it takes precedence over the
rules already there. Detection only asks whether each rule's line is present,
anywhere in the file: the rest of the file isn't judged. A rule already there
below a broader one that matches first (a host all all 0.0.0.0/0 trust, say)
reads present, though the earlier line decides those connections, and a trust line, a duplicate, or any other rule of the file's own is neither
reported nor removed. Review a pg_hba.conf Zap didn't write. The rewrite runs as the postgres account, which must own
the file and be able to write its directory, as a stock install has it; a pg_hba.conf owned by anyone else is left alone and the step fails saying so.
The file is replaced by rename, keeping its group and mode; if the rewritten
copy isn't the original plus that one line, Zap leaves the file alone and the
step fails. A rule Zap added is put in force by reloading the server's
configuration (SELECT pg_reload_conf(), as postgres); a rule already there
reloads nothing. Before the reload the step reads pg_hba_file_rules (PostgreSQL 10 or later; on an older server the step fails), which parses the
file on disk, and fails without reloading if any line of it has an error. The
reload is only a signal, so after it the step waits, up to 30 seconds, for pg_conf_load_time() to move, read from a new connection each second; once it
moves, every new connection, a dependent's included, sees the rule. A postgresql.conf the server can't parse keeps that time from moving, so the
step fails then too, saying so. A reload the server doesn't confirm, a file it
can't parse, or a load time that doesn't move fails the step with the rule in
the file, and the next run's plan reads it present and reloads nothing: fix the
file the server can't parse, then reload by hand
(sudo -u postgres psql -c 'SELECT pg_reload_conf()') once the server can. Zap
reads only that one file: a rule
inside an included file isn't seen, and an include above the first host line still orders ahead of Zap's rule.
swap
A swap file, recorded in /etc/fstab.
zap.swap({id: 'swap', size: '2G'});size(required): the swap size, such as'2G', used when the file is created. It is not compared afterwards: resizing means removing the file by hand first.path(optional): the swap file path (default/swapfile): absolute, with no whitespace, since it is a field of an/etc/fstabline. It is not~-expanded and takes no reference.
A swap file that drifted is repaired part by part: an active one isn't allocated,
formatted, or enabled again, and one already in /etc/fstab isn't listed again,
so an active swap file missing from /etc/fstab is only listed. One that isn't
active is allocated again before it is formatted, so a short file a full disk
left behind is made whole.
Before the file is allocated, formatted, and enabled, as root, its path is
checked: a symbolic link there is refused, since mkswap would format whatever
it points at, and so is any directory on the way to it, up to /, as declared
and as it resolves, that root doesn't own or that a group or other account can
write, where someone else could put a link at the path, or re-point a directory
above it, between the check and the format. The refusal names the path, the
directory, and the reason, and nothing is made.
Adding the entry rewrites /etc/fstab whole: the table as it reads plus the
entry, installed by rename with the table's mode kept and root as its owner.
A table the connecting user can't read fails the step with nothing written, and
so does one with a POSIX access list (ACL), whose mode can't be kept
(file); remove the list (setfacl -b /etc/fstab) to let Zap add the
entry. An
edit someone else
makes between the read and the rename is lost, and a symlinked /etc/fstab is
replaced by a regular file.
firewall
Firewall configuration: default incoming and outgoing policies plus a list of allow rules. There are two backends, ufw (Debian/Ubuntu) and nftables (the native backend on Alpine).
Allow rules are additive. Zap asserts that each declared rule is present and that the default policies match. A rule added out of band is neither detected nor removed.
zap.firewall({
id: 'fw',
backend: 'ufw',
allow: ['OpenSSH', {port: 443, proto: 'tcp'}],
});backend(required):'ufw'or'nftables'.allow(required): the allow rules. See Allow rules.default_incoming(optional): the default incoming policy,'allow','deny', or'reject'(default'deny').default_outgoing(optional): the default outgoing policy, same values (default'allow').
Allow rules
Each entry in allow is either a named ufw profile (a string, such as 'OpenSSH', which takes no reference and can't begin with -) or a port and
protocol object:
port(required): the port number, 1 to 65535.proto(optional):'tcp'(default),'udp', or'any'.
Backends
ufw applies ufw default … policies and a ufw allow per rule, then ufw --force enable. Named application profiles ('OpenSSH', 'Nginx Full')
and port rules both work.
nftables installs a single declarative inet filter ruleset at /etc/nftables.nft (whole, mode 600) and loads it with nft -f.
- The ruleset accepts established connections and loopback before the default-drop policy, so applying it never severs the live SSH session.
- It is scoped to its own table. It does not
flush ruleset, so other tables such as fail2ban's are untouched. - nftables has no application profiles. Use port rules only; a named profile is a config error.
- Boot persistence is the separate nftables service. Declare a
serviceresource (service: 'nftables',enabled: true) alongside the firewall, which loads the same/etc/nftables.nftat boot. This companion service is required: the structural gate rejects an nftables firewall with no enabledservice: 'nftables'in the same config, because a firewall that silently vanishes on reboot is a security regression. Thealpine_web_firewallrecipe bundles both.
For both backends, a status probe that fails (exits nonzero), or an inactive
ufw, detects as absent (needs setting up) rather than holding; a probe that
times out, or whose batch fails, still holds. This is the fresh-host case, where the probe runs before
the same config installs ufw or nftables. See detection.md.
verify
Assertions checked at detection and again on a wetrun. A verify resource
executes nothing of its own. It checks that conditions hold and, if any
assertion fails, becomes a pending change; on a wetrun its assertions run again at its place in the dependency
order, and one still failing fails the run there: changes ordered before it
have already run, and the rest are skipped.
Use it as a guard rail, ordered with depends_on ("this token file must exist
before we proceed"). Like every resource, it is checked
again on every pass.
zap.verify({
id: 'token-present',
assertions: [{type: 'path_exists', path: '/home/deploy/.zap/api_token', file_type: 'file', not_empty: true}],
});assertions(required): the assertions to check. All must pass.
Assertions
Each assertion is discriminated on type.
path_exists: assert a path exists, optionally with a required type, mode,
owner, or non-emptiness.
type(required):'path_exists'.path(required): the absolute path to check, as written: it takes no reference, and a~or relative path is rejected.file_type(optional): the required type,'file'or'directory'.mode(optional): the required octal mode;0644and644are the same.owner(optional): the required owner, as a user name and not a numeric uid. A link atpathis followed: the type, mode and owner are its target's.not_empty(optional): whether the path's size must be above zero (test -s, default false). A directory's size depends on its filesystem, so this doesn't reliably check that a directory has entries.
command: assert a command exits 0, optionally with a substring in its
stdout.
type(required):'command'.run(required): the command to run.expect(optional): a substring that must appear in stdout.
pipeline
An ordered, imperative workflow of steps run sequentially in declared order. Reach for it when a procedure really is multi-step and doesn't map onto a single declarative resource.
A pipeline has no state to converge to. So it runs on every wetrun, a dry
run of a config that declares one always has a pending change (exit 2), and zap status always reads it absent.
zap.pipeline({
id: 'migrate',
steps: [
{id: 'backup', run: 'pg_dump app > /tmp/app.sql'},
{id: 'apply', run: './migrate.sh'},
],
});steps(required): the pipeline steps. See Steps.
Steps
id(required): an id for the step, unique within the pipeline. Two steps with one id are rejected by the structural gate.run(required): the command this step runs.name(optional): a display name for the step. A plan lists each step by itsid, then itsname, above the steps'runcommands.
Steps run in declared order. Concurrent execution and dependencies between steps
aren't supported, so a pipeline-level env, a max_concurrency above 1, or a
step's cwd, env, or depends_on is rejected by the structural gate and
not silently ignored. Order steps by position, and inline any per-step environment or working
directory into the step's run (cd … && FOO=bar cmd).
build
Builds a tool from a fetched source archive and installs it, for upstreams that
ship source and no prebuilt binary (zig, ghostty). The source is content-addressed and provenance-pinned: Zap records which exact source
produced the installed artifact, so a moved upstream is detected as drift and
not silently accepted. It is unrelated to deploy.build, the local command zap deploy runs before packaging (deploy.md).
zap.build({
id: 'ghostty',
source: {
type: 'remote',
url: 'https://release.files.ghostty.org/1.3.1/ghostty-1.3.1.tar.gz',
extract: {type: 'tar.gz', strip_components: 1},
verify: {
scheme: 'minisign',
public_key: 'RWQlAjJC23149WL2sEpT/l0QKy7hMIFhYdQOFy0Z7z7PbneUgvlsnYcV',
},
// blake3: '…', // vendored pin (see below); omit to track via zap.lock
},
run: 'zig build -p $HOME/.local -Doptimize=ReleaseFast',
output: '$HOME/.local/bin/ghostty',
});Fields
source(required): where the source comes from and how its integrity is verified. Seesource.run(required): the build command, run in the extracted source directory, such aszig build -p $HOME/.local. It runs as the connecting user, unprivileged, so anoutputunder a root-owned directory needs the command's ownsudo.output(required): a path that exists if and only if the build is installed. It is the detection probe.cwd(optional): the working directory forrun, taken as written (no reference, no~expansion): an absolute path, or a path relative to the extracted source directory, which is the default.env(optional): extra environment for the whole ofrun, on a remote target as on a local one. Values resolve references and can hold any byte but U+FDD0, U+FDD1, and NUL, soenvis how a build takes a secret. A name can't begin with__zap_.
The build toolchain itself (zig, GTK dev packages, and so on) is not this
resource's job. Declare it with sibling package or installer resources and
order with depends_on.
source
The source is a remote archive:
type(required):'remote'.url(required): the fetch location (URLs). It may carry a reference, resolved when Zap fetches; how the lock addresses it is in lock.md.extract(optional): how the archive is unpacked.{type: 'none'}is the default; the other form is{type: 'tar.xz' | 'tar.gz', strip_components?: N}.strip_componentslives inside the tar form (besidenoneit is rejected), and strips leading path components:1flattens a tarball's single top directory.auth(optional): a bearer credential for a private byte host, resolved at the IO boundary and never hashed.verify(optional): upstream-signature verification. See Verification.blake3(optional): a vendored source pin. Present, the source pins itself; absent, it tracks throughzap.lock, wherezap update <id>advances it; a tracked source with no lock entry, or with an entry gone stale (the declaration changed since it was minted), holds the build (undetermined, exit 1) in the read-only commands. See lock.md.
Verification
When source.verify is set, the fetched archive is verified before it is
cached or pinned, so a tampered fetch can never become a build. The supported
scheme is minisign:
scheme(required):'minisign'.public_key(required): the pinned minisign public key, the 56 base64 characters beginningRWon the key line of a.pubfile. Any other shape fails at config eval.signature_url(optional): an explicit signature URL, which may carry a reference. It defaults to the source URL with.minisigappended to its path, before any query or fragment. The source'sauthgoes with the signature request only when the signature URL has the source's origin (scheme, host, and port); a signature on another origin is fetched without it.
Verification shells out to the minisign binary: the one ZAP_MINISIGN names,
an absolute path, else the first in an absolute directory on PATH. Its exit
status is the verdict that lets the bytes be cached and pinned, so Zap refuses a minisign inside a node_modules directory (an npm script's PATH leads with node_modules/.bin, which any installed package can fill), and one where the
binary, or any directory from where it resolves up to / (and the same for the
directory it was found in and for each link on the way), is writable by a group
or others, or owned by an account other than root and you. Once those
directories are trusted, only root or you could swap the binary between the
check and the run. A refusal or a failed verification names the binary's path. Zap adds no crypto dependency of
its own and uses the upstream's trust mechanism.
A good signature says the key's holder signed these bytes, not that they are the version the URL names: any archive the same key signed verifies, an older release included. The pin is what fixes the version, and a tracked pin is minted from whatever verified archive the URL served at that fetch, so read the pin change in the lock diff as you would a version bump.
It runs once, at the fetch on a cache miss. A cache hit hashes the bytes against the pin again. Bytes that hash to the pin are the pinned bytes, however they reached the cache: a tracked pin is only ever minted from a verified fetch, and a vendored pin is your own statement of exactly which bytes to trust.
Detection
After a successful build Zap writes a provenance sidecar, <output>.zap-build:
a blake3 over the source pin and the build's extract, run, cwd, and env as declared. Detection compares it with the desired provenance, so a moved
upstream or a changed build rebuilds
(detection.md).
See also
- concepts.md: the model and the verdicts.
- configs.md: writing a config; ordering, triggers, the gates.
- detection.md: how each resource type's current state is read.
- lock.md: pins,
zap.lock, and the sentinel and sidecar files. - env.md: references, the env file, secrets.
- deploy.md: the
zap deployrelease mechanism. - recipes.md: the shipped config fragments.
Recipes #
Reusable config fragments shipped with Zap: the standard pieces of a hardened
Linux server, so a config imports them instead of pasting an nginx block or a
systemd unit that drifts from the next one. Each is a pure function or a
constant (no I/O; it runs inside the config sandbox) that gives a string to put
in a file, or a payload to spread into a resource.
import {hardened_systemd_unit} from '@fuzdev/zap/recipe_debian_systemd.ts';
import {debian_web_firewall} from '@fuzdev/zap/recipe_debian_firewall.ts';
zap.file({id: 'unit', path: '/etc/systemd/system/app.service', content: {type: 'inline', content: hardened_systemd_unit({…})}});
zap.firewall({id: 'fw', ...debian_web_firewall()});Import each by its full module path. Resolving the imports: install.md. The generated API reference on the site documents every option; this page is the map.
Host families
Recipes that differ by distro come in symmetric debian_* / alpine_* pairs
over a shared neutral core, so a config imports one family per machine: a
Debian config can't pick up an OpenRC service by accident, because the names
differ. The neutral recipes, identical on every distro, keep bare names. A few
carry the family in the artifact's own name (hardened_systemd_unit, hardened_openrc_service, postgres_apt_repo) rather than a prefix.
Neutral
| Module | Exports | What it gives you |
|---|---|---|
recipe_nginx.ts | nginx_site_core | the portable single-page-app server block: static root, /api proxy with a rate limit, security headers, CSP, and a redirect to it for alias names such as www.; returned as whole template content, the domain and port you pass held to hostname and port. An alias name is literal text, where {{domain}} may stand for the domain (www.{{domain}}), or carries a reference (zap.env('MIRROR_DOMAIN')), which becomes a variable of its own, redirect_domain_<i> for the entry at index i, held to hostname |
recipe_nginx.ts | nginx_catch_all_core, nginx_catch_all_server, nginx_remove_default_core | the catch-all core: the stock default site removed, then a default_server on every socket the site listens on that closes a request for any other host name (444) and refuses its TLS handshake, carrying the site's SSL parameters |
recipe_nginx.ts | ssl_params, rate_limit_zone | the TLS and rate-limit snippets |
recipe_nginx.ts | SSL_PARAMS_PATH | where the TLS snippet goes, the same on both distros; the site wrappers include it from there |
recipe_nginx.ts | RATE_LIMIT_ZONE | the rate-limit zone name an extra_locations block's own limit_req references |
recipe_nginx.ts | site_security_header_directives | the security-header lines, the site's CSP included, an extra_locations block with its own add_header repeats |
recipe_nginx.ts | SECURITY_HEADER_DIRECTIVES | those lines without the CSP, for a block that sets a policy of its own |
recipe_nginx.ts | NGINX_VALIDATE | sudo nginx -t, for a file's validate |
recipe_nginx.ts | nginx_config_verify | a verify payload that re-checks the config every plan |
recipe_csp.ts | spa_csp | a Content-Security-Policy value for a single-page app, with hooks for a websocket origin and extra connect-src, img-src, worker-src entries; pass it to the site recipe's csp |
recipe_ssh.ts | ssh_hardening | an sshd_config.d drop-in (key-only, no root login, AllowUsers the given user, one name with no whitespace, tightened auth tries and grace time); name it to sort first, such as 00-hardened.conf, since sshd keeps the first value it reads |
recipe_ssh.ts | SSHD_VALIDATE, ssh_hardening_verify | sudo sshd -t, for the drop-in's validate, and a verify payload that asserts the hardening in the config sshd applies to the given user's login (Match blocks included) every plan, AllowUsers (the given user and no other, since sshd adds up AllowUsers across files) and the tunables included; give it the options ssh_hardening got |
recipe_ssh.ts | ssh_service_core | the sshd service core: enabled, running, reloaded when its reload_on ids change |
recipe_certbot.ts | certbot_standalone_issue_core, certbot_webroot_switch | the first-issue (standalone) core, which stops nginx with the stop command it is given and then requests the certificate, and the renewal (webroot) script payload, for the site's domain and any alias names. The webroot switch re-issues the certificate and then runs the deploy hook it is given, since certbot before 5.3 runs deploy hooks only on renew. Both run the DNS preflight first |
recipe_certbot.ts | certbot_deploy_hook_core, CERTBOT_DEPLOY_HOOKS_DIR, RELOAD_NGINX_HOOK_NAME | the deploy-hook file core: an executable in /etc/letsencrypt/renewal-hooks/deploy that certbot runs after each renewal, whatever scheduled it, and the reload-nginx hooks' default file name |
recipe_certbot.ts | letsencrypt_live_path | where certbot keeps a certificate's chain and key, /etc/letsencrypt/live/<domain>/; the nginx site and the certbot scripts both read it |
run_parts.ts | validate_run_parts_name, RunPartsFile | the one check of a hook or job file name: letters, digits, _ and - only, the names run-parts runs; and the spreadable file fields the hook and job builders return |
recipe_fail2ban.ts | fail2ban_jail | /etc/fail2ban/jail.local with an sshd jail. It defaults to Debian's journal and a ufw host's iptables-multiport ban action; on Alpine set sshd_logpath: '/var/log/messages' and banaction: 'nftables'. |
recipe_deno.ts | deno_install | a pinned Deno install script, system-wide or into a user's home. Its target triple defaults to glibc; set it on Alpine. |
recipe_sysctl.ts | LINK_PROTECTION_SYSCTLS, LINK_PROTECTION_SYSCTL_PATH, sysctl_conf, sysctl_apply | the kernel's hard-link, symlink, FIFO and regular-file protections (systemd sets them on Debian; Alpine sets only the first two), as a sysctl.d file and a script that loads it when a live value differs |
recipe_firewall.ts | WEB_FIREWALL_ALLOW, web_firewall_allow_rules | the SSH + HTTP + HTTPS allow set as port rules, so both backends read the same rules |
recipe_env.ts | fuz_app_env_base, fuz_app_trusted_proxies | the .env.production content and trusted-proxies stanza for a server built on fuz_app: the header as whole template content (port held to port, cookie_keys to env_value), your own lines appended through extra with their variables and kinds; its DATABASE_URL reaches PostgreSQL over the Unix socket as the service's own account (peer) |
recipe_bootstrap.ts | fuz_app_bootstrap_token | the one-shot first-admin bootstrap token script for a fuz_app server |
Debian and Ubuntu (apt, systemd, ufw)
| Module | Exports | What it gives you |
|---|---|---|
recipe_debian_nginx.ts | debian_nginx_spa_site, debian_nginx_catch_all | the SPA site's template content, for a file in sites-available/ and link into sites-enabled/, and the catch-all: a script removing sites-enabled/default and a file at conf.d/00-catch-all.conf, ordered after it. A site meant to answer any host name declares only the script |
recipe_debian_systemd.ts | hardened_systemd_unit | a unit with NoNewPrivileges, ProtectSystem=strict, ProtectHome, private tmp and devices, restricted address families, an explicit ReadWritePaths list, no capabilities, the @system-service syscall filter, and UMask=0077 (each of the last three loosened by its own option); refuses root as its user or group and an ExecStart prefix that escapes User= or the sandbox |
recipe_debian_certbot.ts | debian_certbot_standalone_issue, debian_certbot_reload_nginx_hook | the first-issue bootstrap script, stopping nginx with systemctl, and the deploy hook that reloads nginx after a renewal (certbot.timer renews, but reloads nothing) |
recipe_debian_ssh.ts | debian_ssh_service | the sshd service on systemd's ssh unit, which Ubuntu's ssh.socket also starts; not for a Debian host with a hand-enabled Accept=yes socket |
recipe_debian_postgres.ts | postgres_apt_repo | the PGDG apt_repo payload |
recipe_debian_firewall.ts | debian_web_firewall | the ufw firewall payload (ufw persists itself) |
Alpine (apk, OpenRC, nftables)
| Module | Exports | What it gives you |
|---|---|---|
recipe_alpine_nginx.ts | alpine_nginx_spa_site, alpine_nginx_catch_all | the SPA site's template content, for a file at http.d/<domain>.conf, and the catch-all: a script removing http.d/default.conf and a file at http.d/00-catch-all.conf, ordered after it |
recipe_alpine_openrc.ts | hardened_openrc_service | a supervise-daemon init script with capability bounding, umask 0077 (loosened by its option), ulimit, respawn, and an optional bwrap isolation tier with an explicit read-write path list; refuses root as its user or group |
recipe_alpine_certbot.ts | alpine_certbot_standalone_issue, alpine_certbot_renew_cron, alpine_certbot_reload_nginx_hook, alpine_periodic_daily_file, ALPINE_PERIODIC_DAILY | the first-issue bootstrap script, stopping nginx with rc-service, the daily renewal script BusyBox crond runs, the deploy hook that reloads nginx after a renewal, and the /etc/periodic/daily placement helper |
recipe_alpine_ssh.ts | alpine_ssh_service | the sshd service on OpenRC's sshd |
recipe_alpine_postgres.ts | alpine_postgres_setup | the OpenRC cluster-init script (apk doesn't run initdb for you), with initdb's trust rules turned to peer and scram-sha-256 |
recipe_alpine_firewall.ts | alpine_web_firewall | the nftables firewall and the nftables persistence service the structural gate requires |
Writing your own
A recipe is an ordinary pure function, so a config can define its own. One that
needs a reference of its own (a default domain, say) makes it with env_ref('NAME') from @fuzdev/zap/env_ref.ts, which returns what zap.env('NAME') does. A value a caller passes in may be a reference too, so a
recipe splices values into strings and never inspects them beyond whether one
carries a reference: one that validates or slices an option fails on a reference. A value that ends up in a file's
inline content can't be a reference; a recipe that takes a per-environment
value emits a {{placeholder}} for it and returns the whole template content, the value in vars and its kind in kinds.
Design rules
The shipped recipes follow these, and they keep your own composable too.
- One value, one home. Anything two recipes must agree on (a rate-limit zone
name, a token path, the ACME webroot) is one constant or flows through one
recipe's option. The nginx rate-limit zone is the precedent:
rate_limit_zonedefines it andnginx_site_corereferences it through one exported constant,RATE_LIMIT_ZONE, so the two can't drift. - A neutral recipe never defaults a distro-divergent value. A default that
differs by family either forces the
debian_*/alpine_*split or becomes a required option. The neutral recipes that still carry a Debian default are flagged in the catalog:fail2ban_jail'ssshd_logpathandbanaction, anddeno_install'starget. - Big divergence, two functions. A one-verb or one-path difference shares a
*_corewith thin wrappers (the nginx catch-all, the sshd service). A whole-artifact difference (a systemd unit versus an OpenRC script) is two functions sharing only an options shape. - Pure. No
fetch, no file reads beyond imports, no clock. A recipe's output depends on its arguments alone, so the plan does too. - Every value a shell reads is one single-quoted word. A recipe that splices
a value into a command or a script quotes it with
single_quote(@fuzdev/zap/shell.ts), whether the value is a reference or a literal, so no value can change what the command runs. An option that is shell words by design (psql_args,command_args, a stop or reload command) says so in its doc. A value a second shell reads again, as OpenRC's start line does, is held to a plain-word pattern and refused otherwise. An env file has two readers, systemd'sEnvironmentFile=and, on Alpine, the init script's reader, sofuz_app_env_baseandfuz_app_trusted_proxiessingle-quote their values, and refuse one holding a'or a line break, which systemd reads differently. - A recipe that owns a template owns its kinds. A placeholder's value is
substituted as text at apply time, so a
'in'{{cookie_keys}}'would end the quoting (systemd would read another value, the OpenRC service would refuse to start), and a;or a}in a value placed in an nginx directive would end the directive. The site and env recipes return the wholetemplatecontent, each variable with the kind of the place it lands (resources.md), so a caller passes values; used whole, the content carries its kinds, and overridingkindsor rebuilding the content drops them.fuz_app_env_base'sextraadds the caller's own lines, variables, and kinds, and can't name the base's; the site recipes take no variables butdomainandport, and add aredirect_domain_<i>of their own for eachredirect_domainsreference. - A
userthatsudo -uruns a command as is written out.sudo -ureads a name starting with#as a uid (#0is root), so a recipe that runs a command as a declared user refuses an empty one, one starting with#, and a reference, whose value it can't see (sudo_as_userin@fuzdev/zap/shell.ts). - A binary a service user owns runs as that user.
deno_installinto a user's home runs itscheckand its closing--versionas that user, never as the connecting user: the account could replace the binary, and the connecting user hassudo.
The certbot DNS preflight
Let's Encrypt limits failed validations, and a fresh host whose DNS isn't
pointed at it yet fails every one. So each certbot script that leads to a CA
request — the --standalone issuance and the webroot switch (a re-issue, and
a new name on the cert is a request for it) — first checks, on the target, that
every name the certificate covers (domain and each of extra_domains)
resolves only to expected_addresses. The checks are unchanged.
- Each name is resolved with
getent ahostsandgetent hosts, which both families have (glibc's on Debian,musl-utilson Alpine). A nameahostscan't resolve refuses, and so do lookups that print no address and any address either prints outside the expected set: the CA validates whichever A or AAAA it picks, so an undeclared AAAA refuses too. - Both lookups, because
ahostsasks withAI_ADDRCONFIG: on a host with IPv6 disabled, glibc and musl both leave the AAAA records out of it, andgetent hostsstill prints them. glibc prints every one, musl only the first, so on a musl host with IPv6 disabled a name with several AAAA, the first of them declared, passes with the others unseen. expected_addressesdefaults to a reference toDEPLOY_HOST, the target's host. Each value is an IP literal or a reference resolving to one; the target checks it when the script runs and refuses anything else, asking forexpected_addresses. ADEPLOY_HOSTholding a host name rather than an IP literal refuses untilexpected_addressesis declared; declare the set too when the names carry an AAAA. An IPv6 address is compared as lowercase text: write it asgetentprints it, compressed.- The issuance stops nginx to free port 80 inside the same
run, after the preflight passes and before certbot runs, so the stop runs only once every name the request covers has passed, and a refusal leaves nginx running. If certbot then fails, nginx stays stopped; the resources after the certificate, nginx's ownserviceamong them, are skipped until a later apply's request succeeds. - A refusal exits nonzero before nginx is stopped or certbot runs, with each reason on stderr:
certbot DNS preflight: example.org resolves to 2001:db8::7, outside the expected addresses (203.0.113.7); the CA validates whichever address it picks, so point every A/AAAA record of the name at this host, or add the address to expected_addresses if it belongs to this host
certbot DNS preflight: www.example.org does not resolve on the target (getent ahosts exit 2); point its A/AAAA records at the expected addresses (203.0.113.7) before certbot runs
certbot DNS preflight: refused; nothing was runThe preflight reads the target's resolver, not Let's Encrypt's, so it can be
wrong in both directions. An /etc/hosts line, a split-horizon answer, or a
cached one can pass while public DNS doesn't point at the host yet, and the
request then fails validation as it would have without the preflight; the same
sources can refuse while public DNS is right. getent reads /etc/hosts first, so a name the host maps to a loopback address there refuses, among them
the 127.0.1.1 <name> line a Debian install writes for the host's own name
when that name is the domain. Remove the line, or give the host a name that
isn't the domain; a tool that manages /etc/hosts may write the line back.
A script has no lock entry and is done once its check passes, so changing its run re-runs nothing: a host whose certificate is issued and pinned to webroot
never runs the preflight, and one whose check fails, for a name newly added to
the cert, runs it before the re-issue.
Env files under OpenRC
OpenRC has no EnvironmentFile=, and the init script runs as root, so hardened_openrc_service never sources env_file. The script carries a reader
that takes each line as data. start_pre runs it as the service user
(su -s /bin/sh), under a 10-second timeout, to check every line and assign nothing.
The check writes to a file in a private directory of root's under /run (mktemp -d), never to a pipe, so a process of the service user's that
outlives the timeout can't hold the start open, and the service user can't
fill the directory beforehand to stop the start, as it could /tmp. Root reads
only the first 4 KiB, which bounds what it reads, not what a lingering writer
can grow the file to;
and the daemon's own process, already dropped to the service user, runs it again
to export the values before it execs the command. Nothing in the file is run
or expanded, root never parses it, and nothing in it reaches root's environment.
The file must be readable by the service user: one it can't read (a root-owned 600 file), a line outside the format, or a file that isn't read in time (a
FIFO) refuses the start, naming the user or the line. The check needs su and timeout on the target, which BusyBox gives Alpine.
The format is a subset of what systemd reads, so a file in it gives the daemon the same environment under both:
- a blank line, or one whose first non-blank character is
#or;, is skipped; - every other line is
NAME=VALUE, the name[A-Za-z_][A-Za-z0-9_]*with nothing before it, and not beginning_zap_; - a value that starts with
'ends the line with its closing'and holds no other; everything between is literal; - any other value is literal as written,
$,#and inner blanks included, and may not start or end with a blank, start with", or hold a\; NAME=sets the empty string, and a later line for a name wins.
Any other line refuses the start, naming its line number, where systemd would
skip it (no =, an invalid name), strip blanks around the name or value, read
double quotes, backslash escapes or a continued line, or let a quoted value span
lines. A carriage return anywhere refuses its line.
Recipes for fuz_app servers
fuz_app_env_base, fuz_app_trusted_proxies, and fuz_app_bootstrap_token are specific to servers built on fuz_app:
its environment layout and its first-admin bootstrap. Everything else on this page
is generic: an nginx site serving a static build with an /api proxy, a
hardened service, a firewall that allows 22, 80, and 443.
Targets, SSH, and privilege #
Where a config acts, how Zap gets there, and what the target needs. What your own machine needs is in install.md.
The target
A config declares exactly one target with zap.target({…}): your own machine,
or a host over SSH.
// your own machine
zap.target({local: true, package_manager: 'apt', service_manager: 'systemd'});
// a host over SSH
zap.target({
host: zap.env('DEPLOY_HOST'),
user: zap.env('DEPLOY_USER'),
home: '/home/deploy',
package_manager: 'apt',
service_manager: 'systemd',
});| Field | Local | Remote | Meaning |
|---|---|---|---|
local | true | omit or false | which kind of target |
host | rejected | required | the SSH host; usually a reference. A name or an address alone: it can't begin with - or hold @, and an IPv6 address is written without brackets. A remote target without one doesn't validate |
user | rejected | optional | the SSH user; ssh's default when the target declares none. It can't begin with - or hold :, and a declared one that resolves to nothing is an error in every command that connects (plan, detect, apply, deploy) |
bootstrap_user | rejected | optional | the alternate user --bootstrap connects as (below) |
port | rejected | optional | SSH port, 1 to 65535. Unset (or 22), ssh's own choice applies, your ~/.ssh/config included |
key_path | rejected | optional | an SSH private key, passed to ssh -i |
home | rejected | optional | the remote home that ~ and $HOME in paths resolve against |
package_manager | apt, apk | same | selects the package backend. pacman and brew parse but have no backend. |
service_manager | systemd, openrc | same | required whenever the config has service intent (a service, a file with triggers, or deploy.service); launchd parses but has no backend |
A local target with any remote field is rejected at parse. A remote path that
uses ~ or $HOME with no home declared is undetermined: Zap never guesses
a remote home.
host, user, and bootstrap_user are connection context, not desired
state: they are left out of every resource's hash, so changing the connecting
user never reads as a config change. The plan does name them: plan_hash covers
the host and the connecting user the target resolves to, and on a local target
the user Zap runs as and its HOME. A target that declares
no user leaves the choice to ssh, and the plan prints user: (none — ssh chooses) instead of a name.
How Zap reaches a remote host
Your authority to act on a host is SSH. Zap shells out to the ssh and scp on your machine with your keys and your ~/.ssh/config, so anything that
works for ssh user@host works for Zap: agents, jump hosts, per-host keys. The
options Zap sets:
| Option | Why |
|---|---|
BatchMode=yes | never prompt for a password; key authentication only |
StrictHostKeyChecking=accept-new | a new host is trusted on first use; a changed key is refused, with a hint in the error |
ConnectTimeout=10 | an unreachable host fails fast |
ServerAliveInterval=30, ServerAliveCountMax=3 | a connection that goes silent is dropped, not waited on |
ForwardAgent=no, ForwardX11=no, ClearAllForwardings=yes | no session, root's included, gets your agent, your display, or a port forward, whatever your config says |
RequestTTY=no, PermitLocalCommand=no | no terminal on the host, and no LocalCommand run on your machine |
Tunnel=no | no tunnel device forwarded, whatever your config says |
ControlMaster=auto, ControlPersist=300, ControlPath="<zap home>/ssh/…" | one authenticated connection per run, shared by every command, and kept warm for five minutes, so a run in that window reuses it rather than authenticating again. Left off when the socket's path would be too long for one (a Zap home over 38 bytes) or the home holds a double quote, a backslash, or a control character; Zap says so once |
ssh keeps the first value it reads for an option, and the command line comes
before every config file, so these hold over your ~/.ssh/config. A changed key
is refused only when the first one was recorded, so before it connects to a host
Zap reads your effective config once (ssh -G, with the same options) and
refuses, naming the setting, when every UserKnownHostsFile is /dev/null.
There is no agent on the host, no daemon, no server, and no shared secret. A pass authenticates once: detection runs as one or two batched shell scripts, and the login batch waits for the root batch's connection to be up rather than opening a second one.
The bootstrap user
A config that hardens SSH often changes the connecting user partway through bring-up: the host's stock account creates a dedicated service user, then SSH hardening locks the stock account out. Declare both:
zap.target({
host: zap.env('DEPLOY_HOST'),
user: zap.env('DEPLOY_USER'), // the hardened service user, steady state
bootstrap_user: zap.env('BOOTSTRAP_USER'), // the stock account, first run only
});Pass --bootstrap on the first run to connect as bootstrap_user, and omit it
afterward. It never engages on its own, so a connectivity failure can't silently
switch identity. The first bring-up is zap apply --bootstrap, then zap apply --bootstrap --wetrun: it provisions and hardens as the stock
account, which the hardening then locks out, and every later command connects
as the service user. A --bootstrap run is its own plan, so preview it with zap apply --bootstrap; a preview made as the service user doesn't bind it.
Keep both variables in one env file, set from the start and kept set: a host or connecting user that resolves to nothing stops every command before it connects, and an unset reference in the other user makes a dry run unverifiable and stops a wetrun.
What the target needs
| Debian / Ubuntu | Alpine | |
|---|---|---|
| shell and tools | sh, GNU coreutils (timeout), util-linux (setsid) | BusyBox, validated on 1.37 (Alpine 3.24): its timeout must take the duration without -t, and its mv must take -T |
| escalation | sudo, working without a password for the connecting user (or you connect as root) | sudo (not doas) |
for file content checks and every file write | sha512sum and a mounted /proc | the same |
| for a file or directory write | mktemp, stat, sync, mv -T, ls, id, and a mounted /proc | the same, as BusyBox applets |
for a remote archive or a build source | sshd's SFTP subsystem (what scp uses from OpenSSH 9.0; an older scp needs scp on the target), mktemp -d | the same |
| for an extracted archive or a deploy | tar with --no-same-owner, --no-same-permissions, and --strip-components; id; rmdir and ls for an extracted archive | the same |
| for a deploy | flock (util-linux), and sort to prune old releases | the BusyBox flock and sort applets |
for a user | useradd and usermod | useradd and usermod from the shadow package (apk add shadow): BusyBox's adduser isn't used |
for user.sudo | visudo | visudo (the sudo package ships it) |
for installer | curl | curl |
for firewall | ufw (the config may install it) | nft plus an nftables service |
for database | PostgreSQL with a postgres system account | same |
Zap needs sudo on the target even when you connect as root: privileged
commands carry an explicit sudo so one plan runs under either user. A fresh
Debian cloud image usually has it; a minimal Alpine needs apk add sudo by
hand or through a bootstrap user that already has it.
Two-tier privilege escalation
Which commands run as root is decided by what they are, not by who you are.
- Intrinsically privileged commands carry
sudo.apt-get install,systemctl,rc-service,ufw,nft,useradd, and the like are baked into the plan with an explicitsudoprefix. The plan reads the same for a root login and a sudoer. - Filesystem operations run under
sudoover SSH, and escalate per operation locally. Over SSH the connecting user isn't assumed to own what Zap manages, so writing a file, making a directory, placing a fetched file, or creating a symlink goes throughsudodirectly. On a local target each is tried natively first and falls back to asudoshell op only when denied, so a config that touches only your own files never invokessudo. A file's mode and owner are part of its write, and a directory's are set the same way: natively, else by one elevated command that refuses a symlink at the path. Afilekeeps the owner and group it leaves unset, so locally a file another account owns, or a new one in a directory another account owns, is written undersudo; so are directories made under another account's, which take its owner. Anywhere below a directory with its sticky bit set or that every account may write, such as/tmp, nothing is kept or taken and your own write stands. Local reads and existence checks follow the same rule: a root-owned file is read undersudorather than misread as absent.
A user's authorized_keys is written as that user (sudo -u <user>), a copy_from source is read as the source user, and a database's SQL runs as postgres (sudo -u postgres), so the connecting user's sudo rule has to
allow running as them. root and an unrestricted sudoer do. What ran as root is in the host's sudo I/O log (security.md).
The exception is a build: its source is extracted into a private temporary
directory and the build command runs unprivileged, as the connecting user. A
root-owned scratch dir would be unrepairable, and a build shouldn't need root.
Local sudo
On a local target, before a root batch runs, Zap checks that sudo will work
without a prompt (sudo -n). If it won't, Zap runs sudo -v on your terminal,
with no deadline, so you can enter a password before anything is timed. The
batch itself then runs under sudo -n. If sudo won't authenticate, every
resource that needed root is held with the reason sudo authentication failed.
An operator command (a script's check, a verify assertion) runs without a
terminal, locally as over SSH. A sudo inside it that needs a password fails at
once and the check reads as failed. Give such a command a NOPASSWD rule, or
keep sudo out of checks.
Detection privilege
Every detection read runs under a declared privilege: login (the
connecting user, unprivileged), root (root on every connection), or elevated (root over SSH; locally, you first and sudo for a read that
failed). Which reads run in which lane, and how batches are framed, killed, and
timed out: detection.md.
Environment and secrets #
How values that vary per machine, or must stay secret, reach a config without being written into it. The sandbox that makes this necessary is described in configs.md.
References
A config never reads the environment. It writes a reference instead, with zap.env('NAME'):
zap.target({host: zap.env('DEPLOY_HOST'), user: zap.env('DEPLOY_USER'), package_manager: 'apt'});
zap.file({
id: 'env',
path: '/srv/app/shared/.env',
content: {type: 'template', template: 'DATABASE_URL={{url}}\n', vars: {url: zap.env('DATABASE_URL')}},
});The reference stays inert in the declaration and in the plan. Zap resolves it from the operator environment when it acts, in these fields only:
| Resolves a reference | Takes none: write the value out | |
|---|---|---|
| target | host, user, bootstrap_user | port, key_path, home |
| paths | resource paths; a package's check and an installer's check when they are paths | a swap's path; an apt_repo's key_path and list_file; a script's or build's cwd; deploy.path |
| content | a template file's vars values | inline content and a template's body (written as declared) |
| commands | script, installer, build, and pipeline commands; an installer's shell; a file's validate; a script's and a build's env values | a verify assertion and a package's check when it is a command (run as written) |
| names | a database's identifiers and auth.hosts | a package name, a service name, deploy.service, a user's shell, ssh_keys.keys, and sudo commands, a named firewall profile |
| URLs | a remote URL and its auth token, a build source's signature URL, an installer's URL, an apt_repo's key_url | an apt_repo's source |
| other | a swap's size, deploy.health.domain | deploy.health.path, and every field not listed |
A reference where none is taken fails when Zap reads the config, naming the resource, the field, and what to use instead:
error: config did not match the zap schema (/home/ops/infra/zap.ts): 2 references in fields that take none:
- resource `motd`: `content.content` takes no reference: the content is written as declared; put the value in a template's `vars`, which resolve
- resource `work`: `cwd` takes no reference: zap enters a working directory as declared and never resolves it; write the path outA reference in a command is replaced as text: put it inside single quotes, or
hand the command an arbitrary value through env, which is quoted after it
resolves. The values Zap refuses in a command, and why: security.md. A template's vars value can't hold a
line break, which would add a line to the file
(resources.md). An env-file value can't hold a NUL. A
value holding U+FDD0 or
U+FDD1 is refused wherever it would be substituted, an env value included: a
value is substituted once and can't carry a reference of its own.
zap.env('NAME') returns a string, so a reference composes like one:
zap.directory({id: 'data', path: `/srv/${zap.env('APP')}/data`});That path has a reference in it, and a recipe can splice a value it was handed
without knowing whether it is one. A recipe
makes its own with env_ref('NAME'), imported from @fuzdev/zap/env_ref.ts.
The name must be an environment variable name (letters, digits, and _, not
starting with a digit).
While the config is evaluated that string is opaque. It holds a marker, not the value, so interpolating and concatenating it are the only things to do with it:
- slicing it, splitting it, or changing its case damages the marker, and the config fails to evaluate, naming the field;
- a regular-expression replace that strips non-word characters, or
encodeURIComponent, silently turns it into ordinary text; - code that inspects a value (its length, its first character) sees the marker.
A reference is the string zap.env or env_ref returns. Text that merely
looks like one is text: $$, $$NAME$$, ${NAME}, and {env:NAME} in a command
or a file are what they say. The two characters the marker is built from (U+FDD0 and U+FDD1) can't
appear in a config's own strings; content that holds them comes from a source or remote file.
Previews and messages show a reference as {env:NAME}:
[+] conf (file)
path: {env:APP_DIR}/app.conf
content: template (47 bytes, sha512 527b3af39db7aeb8…), vars {"domain":{"$env":"DEPLOY_DOMAIN"},"key":{"$env":"API_KEY"},"port":8080}What each reference a plan uses resolves to is printed once, in a references: block under the target, and is part of the plan and its hash: a plain
reference's value, or for a secret only that it is one. So a value
you wouldn't want in a preview or a CI log belongs under a name Zap reads as a
secret, or in zap.mask.
references:
API_KEY is a secret: its value is not shown, and no part of it is in the plan or its hash
APP_DIR = /srv/app
DEPLOY_DOMAIN = app.example.comText in a command can spell {env:NAME} too. Where it matters which it is, the target: line and a template's vars print the exact form, {"$env":"NAME"},
and plan_hash covers it.
A command, a path, or an SSH destination that still holds an unresolved reference is never run, touched, or connected to: Zap refuses first.
Target-categorized resolution
Two kinds of placeholder resolve against two different machines:
| Placeholder | Resolves against | In the plan |
|---|---|---|
zap.env('NAME') | the operator's environment | as a reference; the value arrives when Zap acts |
~, $HOME | the target's home: target.home remotely, your own locally | as written |
So a resource's part of the plan doesn't depend on the environment or the
target's home, and a remote path that needs a home with none declared is
undetermined rather than guessed. The home itself is part of the plan beside
it: a remote one as the declared target's home, your own as the local plan's home: line. A resource's resolved values reach the hashes
of the last-run report and, for a tracked source, the lock.
The target's host, user, and bootstrap_user resolve from references too;
they are connection context (targets.md).
The env file
Every config command accepts --env-file <path>, a file of NAME=VALUE lines
loaded into the operator environment before anything resolves. A name set in
both the file and your shell takes the file's value:
zap apply --env-file zap.env
zap deploy --env-file zap.env --wetrunOne explicit file, no auto-discovery, no .env plus .env.<mode> merging.
Keep env files out of version control and commit a .example with the names.
Name it for what it is, say zap.env or zap.production.env; don't reuse the
name of an env file your app reads on the host, which deploy.shared may be
linking into releases.
The format is a strict subset of dotenv: Zap reads only lines the common
loaders agree on, and a line it doesn't understand is an error instead of a
guess. (A loader that interpolates still reads a $ its own way.)
| Line | Reads as |
|---|---|
blank, or beginning # | nothing |
NAME=value | the value to the end of the line, surrounding whitespace trimmed |
NAME='value' | the value as written |
NAME="value" | the value, with \\, \", \n, and \r decoded |
export NAME=value | the same as without export |
A name is letters, digits, and _, not starting with a digit. There is no
interpolation ($OTHER is text) and no value spanning lines. These don't load:
a line with no =, a name that isn't one (export counts only before a
space), a name assigned twice, a quote left open or followed by anything but a
comment, an unquoted value that begins with # or holds one after whitespace
(quote the value, or move the comment to its own line), an unquoted value
ending in a backslash or holding a control character other than a tab, or a
Unicode line or paragraph separator (U+2028, U+2029), and a NUL in an assignment. Whitespace around the = is
trimmed, and a byte-order mark at the start is skipped. The error names the
file, the line, and the name when the line has one, and never any part of a
value or of the line.
Zap warns, once per command, about an env file its group or everyone can write
(whoever can rewrite it chooses the host a deploy goes to), and about one they
can read when it holds a secret by its name or the form of its value (the file
is read before the config, so a variable only mask() lists doesn't count),
saying which. chmod 600 answers both.
Unset references
A reference with no value, or an empty one, is handled by what the command does:
- A wetrun (
applyordeploy) refuses to start. Every referenced variable must be set and non-empty, or the run fails before any side effect. An emptyDATABASE_URLwould otherwise be written into a.envfile as nothing. - A comparing dry run (
plan,apply,detect) is unverifiable. Detection resolves references into the desired side of its comparison, so an unset one makes the answer unsound in both directions: it can hide drift (the target already holds the empty value) and invent it (the target holds the real one). The command leads with the banner and exits 1:UNVERIFIABLE — 2 referenced variable(s) are unset or empty, so this comparison cannot be trusted: DATABASE_URL, SMTP_PASSWORD an unset reference resolves to an empty string, which can both hide real drift and invent drift that isn't there. provide them (--env-file or the environment) and re-run.The check covers what the comparison resolves: the resources, and the target's
host,user, andbootstrap_user. The user the run connects as is held to more: if it resolves to nothing the command stops before it connects (below), sincesshwould pick an account of its own. It ignores thedeployblock and a fetch credential (theauthtoken of aremotefile or abuildsource). Adatabase'sauth.hostsis part of the comparison and is covered. - A deploy dry run warns. It compares nothing (the preflight is wetrun-only), so its rendered plan is sound.
- A remote
host, or a declared connecting user, that resolves to nothing is an error before any connection. Zap won't SSH to nowhere, or as whichever accountsshpicks:plan,detect,apply, anddeployexit 2, naming the variable that is unset or empty.
Unverifiable
The banner above is the unverifiable verdict: the desired side of a
comparison couldn't be built. It is the twin of undetermined, where the
observed side couldn't be read. Both outrank "converged" and "drifted" in the
exit code, because neither claim was established.
Secrets
One rule decides whether a reference is a secret, and everything that treats secrets differently asks it. In order:
- Where it is used. A reference in a
remotefile's or abuildsource'sauthis a secret, whatever it is called. - What you said.
zap.unmask('NAME')makes a reference plain, andzap.mask('NAME')makes it a secret. Unmasking a reference used as anauthcredential is an error. - Its name, by the rule in Secret names below.
- The form of its value. A value is a secret, whatever its name, when it
holds a URL with a password (
postgres://app:…@db/app, so aDATABASE_URLneeds nomask), anhttporhttpsURL with a user,git+httpsincluded (https://<token>@git.example.com/…: HTTP reads a user only as a credential, wheressh://git@…andpostgres://app@…name an account), a PEM private key, or an HTTP authorization value (Basicwith its encodeduser:password, orBearerwith a token of 16 characters or more, alone or as anAuthorization:orProxy-Authorization:header line). A password holding an unencoded?or#still counts when no@comes before that character (postgres://app:p#ss@db/app, as libpq reads it: up to the first@after it), unless what precedes it is all digits, which reads as a port. A password in a query string or inkey=valueform, and a secret in a URL's path (a webhook's), aren't recognized: name the variable for what it is orzap.maskit.
What is masked is a value Zap resolves: a name no field references registers
nothing, so zap.mask doesn't scrub a value a command reads from the target's
own environment.
Secret names
Case-insensitive. A name is a secret's when it:
| Words | |
|---|---|
| holds anywhere | SECRET, PASSWORD, TOKEN, KEY, CREDENTIAL |
| has a last word ending in | PASS, PASSWD, PASSPHRASE, PASSCODE, PWD, BINDPW, ROOTPW (ordinary words like BYPASS and COMPASS, and the shell's own PWD and OLDPWD, aside) |
| has a last word that is | PW, PAT, DSN, AUTH, PRIVATE, SALT, COOKIE, SESSION, JWT, CRED, PSK, WEBHOOK; or URL or URI after WEBHOOK or WEBHOOKS |
The last word is the part after the final _, passing over a trailing number
written apart or glued on (SMTP_PASS_2, DB_PASS2; AUTH0 is a name, not AUTH), a version (DB_PASS_V2), and a qualifier naming an environment or a
rotation (PROD, PRODUCTION, STAGING, STAGE, DEV, DEVELOPMENT, TEST, TESTING, QA, UAT, OLD, PREV, PREVIOUS, NEW, NEXT, CURRENT, PRIMARY, SECONDARY), so DB_PASS_PROD is a secret. A plural reads
as its word (DB_CREDS, HASH_SALTS), except for AUTH, PRIVATE, SESSION, COOKIE, WEBHOOK, and a word ending in PASS, whose plurals count or switch
something (MAX_SESSIONS, MAX_PASSES).
So DB_PASS, GITHUB_PAT, SENTRY_DSN, and SLACK_WEBHOOK_URL are secrets,
and AUTH_HOST, PRIVATE_IP, and SESSION_TIMEOUT aren't. A secret-named
variable is plain while its value is a switch (true, false, yes, no, on, off, or a single digit, in any case, whitespace around it ignored), so ENABLE_AUTH=true masks nothing.
Masked hosts and review
A target.host, a connecting user, or a deploy.health.domain behind a secret
reference makes a plan no review can bind, since its hash would name no one
machine or be computed from the secret. zap apply --wetrun and zap deploy --wetrun refuse it before anything runs, a dry run shows the plan
under an UNBINDABLE note with no plan_hash and records no preview, and a
masked host keeps no last-run report. The message names the cause:
| The reference is a secret because of | Way out |
|---|---|
| its name or the form of its value | zap.unmask('NAME') if it isn't one, or write the value literally |
the config's zap.mask('NAME') | drop the mask |
| its use as a fetch credential too | reference another variable in the host, user, or domain, or write the value literally |
| a registered secret carried in its value | take the value from a literal, or from a variable that carries none |
What masking covers
A secret's resolved value is masked out of everything Zap prints or writes:
the text report, --json events, warnings, held reasons, drift observations,
deploy step output, and errors. Masking is by value. Besides the value itself
Zap looks for it trimmed, URL-encoded, JSON-escaped, and single-quoted for a
shell as Zap's own commands carry it (each ' written '\''); for each line of 24
characters or more of a value that spans several (a PEM key, with its \n decoded when it was written on one line; the -----BEGIN…----- lines aside);
and for the password or token inside a URL or an authorization value, when that
is 8 characters or more. A password that also appears elsewhere in its own
value (postgres://app:postgres@db/app) is an ordinary word and is masked only
as part of the whole. A secret a tool prints in any other form (the address ssh resolved a secret
host name to, a URL it rewrote around a short password) is not found.
The executor itself never sees a masked string; masking
happens where output is consumed. A plan's steps hold only the inert
reference; its references: block holds the value of a plain one, with any
secret inside that value masked, and for a secret only the fact. Commands reach their shell on stdin, so a
resolved value is on no ssh or shell argv
(security.md). There is no --show-secrets.
The same rule decides how zap.lock addresses a tracked source: a secret
reference is hashed as a fixed token, any other by its value
(lock.md).
The last-run report on disk is masked the same way. Each resource's hash
takes a secret reference as a fixed [MASKED:NAME] token and masks every
registered secret out of any other value, so the report can't be used to test a
guess at a secret, and a changed secret value isn't visible to zap status (which says so beside each resource that references one). status rebuilds
its masker from its own environment, so for an exact "unchanged since" it needs
the same env the run had, secrets included; without one it fails closed to
"changed since".
A literal secret in a config is rejected by every command: when a value that is both referenced and resolvable in the current environment also appears written out in the config, the secret gate fails, naming the field and the variable and showing none of the text. It looks for a secret's whole value, 8 characters or more: a shorter one turns up in ordinary text by chance. It is best-effort by design, so it never fails spuriously on a machine that simply lacks the secret.
The lock file and Zap's state #
How Zap pins the evaluator that runs the config and the content it fetches,
what zap.lock holds, the commands that move it, and what Zap keeps under ~/.zap. Background: concepts.md.
zap.lock is declared intent, not cached state. It belongs in version
control with the config. It records the toolchain pin and the tracked pins;
everything else Zap knows about a machine it reads live. The file is the
config's name with .lock for its extension, a regular file: a config itself named *.lock is refused
(the lock would overwrite it), and so is a symlink where the lock goes, by every
read and every write.
The toolchain pin
Every command but zap lock evaluates the config only with the fgro build
(Gro's runtime) whose blake3 the lock's toolchain records:
{
"version": 3,
"toolchain": {
"fgro_blake3": "…"
},
"entries": {}
}zap lock writes it: it hashes the binary --fgro or ZAP_FGRO names,
evaluates the config with that binary, and records its blake3, whatever the
lock pinned before. It prints the pin, and the one it replaced when the pin
moved:
$ zap lock
pinned fgro d5d10656aab9b1126949c67260afe0f06fbb208230306efd245186f2c4462c22
wrote zap.lock (no eligible resources)A moved pin reads pinned fgro <new> (was <old>). Every other command reads the
pin before it evaluates:
- no pin, because there is no lock or no
zap lockhas pinned one in it, refuses the evaluation, exit 2, saying to runzap lock; - a binary that hashes to something else refuses, exit 2: run
zap lockto pin the build you mean to use, or point--fgroat the pinned one.zap updatedoesn't re-pin it: moving the evaluator iszap lock's alone, so it shows up in the lock's diff by itself; zap lock --checkreports a binary off its pin as a stale toolchain pin, exit 1, and evaluates nothing, so the entries go unchecked until it is pinned.
A build of fgro is not reproducible across machines, so the pin names one
binary: another machine evaluates the config only with a copy of the same
bytes, or after its own zap lock, which shows in the lock's diff. An
unchanged binary isn't hashed in full on every command
(On-disk state).
A "version": 1 lock has no toolchain pin: it reads, and every command refuses
until zap lock rewrites it. A "version": 2 lock reads as it is; the next
write records it as version 3, which a zap build that reads only version 2 refuses. A
lock with a version newer than this zap reads is refused, zap lock included.
Two kinds of fetched content
A file with remote content, a build, whose source archive is fetched and
built, and an apt_repo, whose signing key is fetched and installed, take bytes
from a URL. Each is pinned by the blake3 of the
fetched bytes, so the same declaration always resolves to the same bytes. The
pin lives in one of two places:
| Where the pin lives | Lock entry | How it moves | |
|---|---|---|---|
| Vendored | in the config: blake3: '…' on the remote content or the source, key_blake3: '…' on an apt_repo | none | edit the hash in zap.ts |
| Tracked | in zap.lock, next to zap.ts | one per resource | zap update <id> or zap lock |
Vendored is self-contained: a URL and a hash, both in the config, and editing
the version means editing the hash. Tracked is the reviewed-upgrade path: bump
the version in the config, run zap update, and read the new pin in the lock
diff before committing both.
What an entry holds
{
"version": 3,
"toolchain": {
"fgro_blake3": "…"
},
"entries": {
"pgdg": {
"kind": "apt_key",
"config_hash": "…",
"blake3": "…"
},
"zig": {
"kind": "remote_file",
"config_hash": "…",
"blake3": "…"
}
}
}kind is remote_file, build, or apt_key. blake3 is the pin, 64 lowercase hex with no prefix. config_hash, in the
same form, is the blake3 of the resource's declaration with the pin folded in
and Zap's resource-hash version ahead of it: the fixed point the entry was
minted for. It is how Zap knows an entry is stale: edit what the hash covers
(the URL, a strip_components, a build's run, an apt_repo's source) and the recomputed hash no
longer matches, so the pin is no longer known to be the pin for this declaration. It leaves out what doesn't change what the pin is the pin for: the
resource's identity (id, name, depends_on), an auth credential, and a file's validate command. A new Zap version that changes what the hash covers
reads every entry stale once, until zap update.
The hash is unkeyed. It tells a stale entry from a current one, not a genuine
entry from a forged one: whoever can edit zap.lock can write a matching config_hash for any pin. The lock is trusted as reviewed input, like the
config beside it, so review its diff before committing it.
zap.lock is committed, so the hash must not be a way to test a guess at a
secret. A reference that is a secret (by its use as an auth credential, by mask(), by its name, or by the form of its value) is
hashed as the fixed token [MASKED:NAME], whatever its value. Consequences:
- Rotating a token in a tracked URL doesn't make the entry stale. The lock verifies on a machine that doesn't have the secret when the entry's URL and path hold no non-secret reference, and when the secret is one by more than the form of its value (a variable secret only by that form has to be set).
- A reference that only looks like a secret (
KEYCLOAK_VERSION) loses its staleness signal too.zap lock,zap update, andzap lock --checkprint anote:naming each reference addressed this way because of its name or the form of its value, with the reason;zap.unmask('NAME')makes a non-secret one count again. - Whether a reference is a secret can depend on its value: a URL is one only
while it holds a password, and a secret-named variable isn't one while it
holds a switch (
true,1). An entry minted under one reading and checked under the other never reads current: it reads stale, or unverifiable when the variable is unset and only its value would make it a secret.
Any other reference is hashed by its resolved value, with every secret value
the config references anywhere replaced by its token where it occurs inside it,
as the output masking replaces it: a DOWNLOAD_URL that carries the value of a SIGNING_SECRET the config also uses is hashed with that part as [MASKED:SIGNING_SECRET], so its entry moves when the rest of the URL does and
not when the secret is rotated with it. A secret shorter than 8 characters, or any
form of it shorter than that (trimmed, or with its \n decoded), is not replaced:
replacing zig wherever it occurs would move unrelated addresses with the
secret's value, and so few characters are guessable anyway. A secret can only
be replaced while it is set. While a variable the config uses as a secret (by its
use as a credential, mask(), or its name) is unset, zap lock and zap update don't mint an entry whose URL or path holds a non-secret reference, and name the
variable to set. Such an entry already in the lock is still checked: an address
that matches reads current, and only one that changed reads unverifiable, naming
the variable, since the change may be the secret's. A value that carries a secret
under an ordinary name (a BASE_URL with a token in it) that no secret reference
names should be listed in mask(). A build's run, env values, and verify block are hashed as declared: a reference there counts as the reference, whatever
its value, so the entry moves only when the declaration does.
Standing
Every command that reads the lock classifies each tracked resource:
| Standing | Meaning |
|---|---|
| current | the entry's config_hash matches the resource |
| missing | the resource is tracked but has no entry |
| stale | the resource changed since the entry was minted |
| unverifiable | a variable the hash depends on is unset: a non-secret reference, or a secret a non-secret value could hold |
| orphan | an entry for a resource the config no longer declares (warn only) |
zap lock --check reports the standing as its gate and exits 1 for missing,
stale, or unverifiable. The read-only commands (plan, detect, status, the
deploy preflight) lead their output with a LOCK banner for the same findings. apply refuses a stale or unverifiable entry instead, as does a deploy wetrun's preflight, with exit 2 (cli.md has every command's exit
codes).
Who writes, who reads
Only apply --wetrun and the lock verbs write the file. Each keeps the
toolchain pin it found, except zap lock, which sets it. Every other command but zap version validates the file, and refuses one that doesn't parse.
A write takes an exclusive flock on zap.lock itself, opened for writing, so
concurrent writers take turns. apply --wetrun and zap update merge into the
file as it stands when they hold it, so neither loses entries another wrote; zap lock rewrites the file whole, reading the old one only for the pin it
replaces and to refuse one newer than it reads, so it rebuilds one that no
longer parses, such as one left with merge-conflict markers. A zap.lock is
refused (zap.lock is not writable) for a user who can't write it, rather than
replaced, and a lock Zap can't create says its directory isn't writable. A zap.lock that is empty reads as no lock.
zap apply: a dry run resolves any missing tracked pin over the network, the one network read a dry run does, and reports it asresolved, not yet written to zap.lock. A wetrun writes the resolved lock before executing, so an apply is never recorded without its intent. A stale or unverifiable entry is an error on apply, never auto-reconciled: the message says what to run.- No pin is minted from a URL Zap can't fully resolve. If a tracked URL
references a variable that is unset or empty,
zap lockrefuses (exit 2) andzap updatefails that entry, naming the variable; nothing is fetched with the reference blank. A dryzap applyholds the resource instead (exit 1), and a wetrun's env gate has already stopped the run. zap lock: rebuild. Pins the toolchain, re-fetches every tracked source, re-mints every entry, drops orphans, rewrites the file. All or nothing. The only verb that prunes, and the only one that moves the toolchain pin.zap lock --check: offline classification, no network, no detection, after the toolchain pin is checked against the binary.zap update [ids…]: re-fetch the named resources, or all tracked ones. An entry is rewritten when its pin advanced or when itsconfig_hashwas stale (same bytes, edited resource), so thezap update <id>the stale error prescribes always clears it. Each advance is written as it happens; a failure later in the run keeps the earlier advances.plan,detect,status, deploy's preflight: read-only. They never mint, and they withhold any pin that isn't current. The resource then behaves exactly like one with no pin:undetermined, held out of the changeset, exit 1 (the preflight bails with exit 2), so a stale pin can't makeplanreport "converged" for a configapplyrefuses.
What detection compares
The pin is the desired side of the comparison; detection never fetches. An
unextracted remote file is compared by digest against the pinned bytes; an
extracted one (extract: tar.*) through its extract sentinel, a .zap_remote_hash file in the destination recording a digest of the tarball pin
and the extract form; a build through its provenance sidecar, <output>.zap-build, recording a digest of the source pin and how it is built
(extract, run, cwd, env); an apt_repo's key by the blake3 of the key
file's bytes against its pin. A pin change re-extracts, rebuilds, or reinstalls
the key, and so does an edit to what the digest covers. The details are in detection.md and detection.md.
Verification
A build source that declares verify (minisign) has its signature checked before the archive is cached or pinned, so a tampered fetch never becomes a
pin; a cache hit re-hashes the bytes against the pin instead. The fields and
the trust argument: resources.md.
What the lock does not cover
Package versions, installer versions, and anything else that isn't a tracked remote file, build source, or apt_repo key. A package is detected by the manager's query through
the package manager; an installer by its check path. Drift detection for
every other kind is the kind's own live reads.
On-disk state
Zap keeps its own working state under ~/.zap, or $ZAP_HOME, separate from
the config and the env file. Either path must be absolute; an empty ZAP_HOME counts as unset. Every directory Zap keeps state in (the root, cache/ and
everything under it, ssh/) is yours alone (mode 700) and every file it
writes there is 600; bin/ is the installer's, and yours. Zap tightens a state
directory it finds looser, and refuses to run (exit 2) over one that is a
symlink, isn't a directory, belongs to another user, or can't be opened, since
the records in it decide what a wetrun may execute:
| Path | Holds |
|---|---|
bin/zap | the installed binary |
bin/fgro | where install.md suggests keeping the fgro build ZAP_FGRO names, apart from Gro's build output; Zap never writes it |
cache/<config-slug>/last_run/<host>.json | the last-run report for this config on this host, latest only. A host that isn't a safe file name (a path separator, say) is filed as host-<short-hash>.json. No report is kept for a host that resolves through a masked reference. |
cache/<config-slug>/deploy_preview/<host>.json | the deploy preview for this config on this host: the plan_hash the last zap deploy dry run (or refused wetrun) showed, when, and which pass. Withdrawn by a deploy wetrun before its first step. |
cache/<config-slug>/eval/ | the eval cache: the config's evaluated JSON beside a manifest of its path, the driver and fgro build it ran on, each file the evaluation read with its blake3, each path a resolution found empty, and the evaluated JSON's own blake3; an entry is served only for the config it was evaluated from, and only the JSON its manifest names. See ZAP_EVAL_CACHE in cli.md; --no-cache bypasses it. |
cache/fgro_pins/ | the fgro binary's blake3, keyed by the file's inode, size and times, so an unchanged binary isn't hashed in full on every command (The toolchain pin) |
cache/files/<blake3>/<name> | the content cache: bytes for remote files and build sources, by content address, checked against their blake3 on every read |
ssh/ | SSH ControlMaster sockets for remote targets (targets.md) |
bin/ is always ~/.zap/bin: cargo xtask install doesn't read ZAP_HOME.
ZAP_FGRO, which names the fgro binary every config is evaluated with, is held
tighter than ZAP_HOME: it must be an absolute path, and an empty one is an
error (exit 2), not an unset one, since it names what runs your config
(install.md). Under sudo, the state home, pin
cache included, is the one sudo's HOME gives Zap.
None of this is part of the declared state or the plan hash, and none of it
stands in for reading the target. zap.lock is the exception by design: it
lives next to zap.ts and is committed, because it records intent.
Detection #
How Zap reads the current state of a target: the verdicts, how deeply each resource kind is read, the batched reads behind every pass, and the last-run report. concepts.md is the short version.
Detection is the detect step of describe → detect → plan → apply → observe. For each declared resource it answers one question, does the desired state already exist?, with one of four verdicts, defined in concepts.md. This page is how each is reached.
A fifth word, residual, describes a resource that still doesn't read converged
after a wetrun acted on it. It is covered below.
The governing principle
Detection depth should match execution idempotency. If re-executing a
resource is a true no-op, shallow detection is fine. If re-execution has side
effects even when the state is already correct (it restarts a service,
downloads bytes again, fires a restart_on chain), detection must be deep
enough to skip when nothing needs to change.
Detection runs at one of three depths:
| Depth | Question | Trust |
|---|---|---|
| Existence | does the thing exist at all? | low: misses attribute drift |
| Attribute | does it exist with the right properties? | medium: catches most drift |
| Content | is the full content or state correct? | high: catches all of it |
Per-resource depth
| Kind | Depth | Reads |
|---|---|---|
file | content | existence, SHA-512 digest, mode, owner, group |
link | content | the symlink's target, as the login user |
build | content | the provenance sidecar (below) |
swap | attribute | the file, its /proc/swaps entry, its /etc/fstab line; not size, so a resize isn't drift; a drifted swap repairs only the missing parts |
database | attribute | the role, the database, its owner, its pg_hba rules |
firewall | attribute | active, the default policies, each declared allow rule |
apt_repo | content | the key's blake3 against its pin, and the whole list file against the resolved, signed-by source line |
service | attribute | running and enabled (below); config freshness through triggers |
directory | attribute | existence; mode, owner, group when declared |
user | attribute | each declared attribute |
package | existence | the manager's query (which forms compare a version) |
installer | existence | the check path; no version or integrity probe |
script | yours | its check command's exit |
verify | assertions | each assertion, at detect time (below) |
pipeline | none | nothing: always absent, so a dry run of a config with one always has a pending change (exit 2), and zap status reads it absent |
Notes on the kinds that need them:
file- A file that exists but can't be read holds as
undetermined. So does an unreadablestatwhen any of mode, owner, or group is declared. Neither reads absent or converged. - With a mode, owner, or group declared, the path itself must be a regular file. A symlink there drifts even when what it points at matches, and the write replaces it.
- The content is compared by SHA-512 digest, so only the digest crosses the wire. See content is compared by digest.
- One carve-out: an extracted
remotefile (extract: tar.*) is compared through its extract sentinel, a.zap_remote_hashfile in the tree, and the extracted tree is not hashed again. The sentinel records a digest of the tarball pin and theextractform with itsstrip_components, so a new pin or strip count re-extracts. - An extracted tree is read the way the extraction treats it, so a plan never
offers a change the wetrun refuses. Nothing at the path reads
absent. A symlink, or anything but a directory, at the path holds. The sentinel counts only as a regular file, not a link. A directory without one readsabsentwhen it is empty, and otherwise holds: Zap didn't extract it, and won't replace it.
- A file that exists but can't be read holds as
link: read as the login user. A path that isn't a symlink, or one the login user can't reach, readsabsent, and a regular file there is replaced by the link on apply; a directory there fails the step. See how detection reads the target.database- One
psql -Xquery as thepostgresrole reads the first three and thehba_filesetting.-Xmeans no~/.psqlrcadds to its output. - Each declared rule is then a
grep -qEof that file. No matching line reads the rule as missing, and a filegrepcan't read holds. - The query runs under a
timeoutof its own (50 seconds), which stopspsqlitself and not only the shell that started it.
- One
firewall- A rule is matched against the whole rule target in the live ruleset, never a
substring, so
allow 80is not satisfied by an8080/tcpline, and a default-policy flip made out of band drifts. - ufw: a rule counts only as the row
ufw allowwrites, an inboundALLOWfromAnywhere, matched on its parsedTocolumn inufw status verbose(verbosefor theDefault:line). A named profile matches either its bare name or the verbose<ports>/<proto> (<name>)rendering, multi-word names likeNginx Fullincluded. So an out-of-bandufw deny 22/tcp, an outbound allow, or an allow from one address drifts. The status is read in theClocale so its prose parses the same whatever the operator's locale. A status with no readableDefault:line holds, and does not read the policies as converged. - nftables: the read is
nft list table inet filter. A rule counts only as a whole rule line of theinputchain, set aside only what doesn't change what it admits (acounterand its totals, acomment, a# handle). Soip saddr 1.2.3.4 tcp dport 22 acceptoriifname "lo" tcp dport 22 acceptdoesn't satisfy a declared22/tcp. Each base chain's effective policy is read: thepolicyverb refined by the trailing barerejectrule, so a desiredrejectis distinguishable fromdrop. A missinginputoroutputchain drifts asmissing. - Allow rules are additive: each declared rule must be present, but a rule added out of band is neither detected nor removed.
- A rule is matched against the whole rule target in the live ruleset, never a
substring, so
directory- An unreadable
statholds asundeterminedwhen any of mode, owner, or group is declared. With none declared, existence is the whole desired state. - Existence is read as the login user and follows a symlink (
test -dsemantics), so a directory the login user can't reach readsabsent. - With a mode, owner, or group declared, a symlink to a directory drifts, and the change fails without touching what it points at.
- The mode, owner, and group are read only when one of them is declared: as root over SSH, and as the operator locally.
- An unreadable
user- With
home_modeorssh_keysdeclared, the account's/etc/passwdhome is read: a home other than/home/<username>, or none, holds the user, since Zap acts on paths under/home/<username>(resources.md). - With either declared, what is at
/home/<username>is read with astatthat doesn't follow a link, for an account that doesn't exist yet too: nothing there, or a directory the user or root owns, reads on (an existing account's root-owned home is left as it is; the creation of an absent one gives it to the user). Any other owner, a link, or anything but a directory holds the user. A/homethat is missing, or that the connecting user can't search, holds the user naming/home, rather than reading as nothing there. - SSH
authorized_keysand sudoers, compared by content with surrounding whitespace trimmed. Up to 1 MiB ofauthorized_keysand 64 KiB of sudoers are read; past that the file drifts. - Supplementary group membership (
id -nG). It is additive: a desired group that isn't present drifts. - An
ssh_keysaccount's/etc/shadowpassword field is held authoritatively at the disabled*. Any other value drifts: a!lock, an empty field, or a hash. - A probe of a declared attribute that exits nonzero (a
statof a missing home, withhome_modedeclared) holds the user: the attribute wasn't read, so it isn't called converged. An attribute the config doesn't declare isn't read at all. A key file that can't be read is drift. - A
copy_fromsource'sauthorized_keysis found through that user's/etc/passwdhome and read as that user, the way apply reads it, so a source file that user can't read drifts on every plan and fails on every apply.
- With
package: a package held back, or installed for any architecture, is installed; one half-installed or removed with its configuration left is not. A query that fails holds the package.
The shallow rows are deliberate, not unfinished. A package install or an installer re-run is idempotent, so existence detection is enough. script hands the depth to you: make its check precise or it runs on every apply.
build: exact provenance, not a version string
A build is content-addressed by the blake3 of its source archive, the
pin: the vendored source.blake3, or else the zap.lock entry. After a
successful build Zap writes a <output>.zap-build sidecar recording its provenance: 64 lowercase hex, the blake3 of a fixed tag, a format version,
and the pin with the build's extract, run, cwd, and env as declared.
Detection computes the desired provenance the same way and compares:
| What detection finds | Verdict |
|---|---|
| output or sidecar missing | absent |
| sidecar differs from the desired provenance | drifted: the upstream moved or the build changed, so rebuild |
| sidecar equals the desired provenance | converged |
| no usable pin (a tracked source with no lock entry, or a stale one) | undetermined in plan, detect, status and a deploy's preflight (which bails, exit 2); apply mints a missing pin and refuses a stale one (exit 2) |
A reference in run or env is part of the provenance as written, not as
resolved: a secret's value never reaches the target, and a new value for a
variable alone does not rebuild.
This is why a --version check isn't the drift signal. The provenance is exact
and language-independent, so a moved upstream is caught even when the installed
binary still runs. See resources.md.
verify: assertions at detect time
A verify resource runs its assertions during detection. If all pass, it
reports converged and is skipped. If any fail, it becomes a pending change; on
a wetrun its assertions run again at its place in the dependency order, and one
still failing fails the run there, so an earlier change in the same run can
satisfy it. A dry run shows it as a pending change and runs nothing. A verify
never executes anything of its own. It is a guard rail, and changes ordered
before it have already run, so order it with depends_on to put the guard where
it belongs.
Because the answer is live state, a verify is checked again on every pass.
For a gate that should run only after something else applies, use a script with check and run, ordered by depends_on. That keeps depends_on meaning
ordering, not a detection special case.
Service probes
A service's two probes read what the manager reports, and a probe that fails to
read holds the service: a failed read is no evidence of a stopped or disabled
service, and reading it so would take running: false or enabled: false as
converged.
- systemd:
systemctl is-activeexiting 0 is running, 3 (inactive or failed) and 4 (no such unit) not running;systemctl is-enabledexiting 0 is enabled, and a non-zero exit is not enabled only when it printsdisabled,masked,masked-runtime,linked,linked-runtime, ornot-found. - OpenRC:
rc-service <svc> statusexiting 0 is started, 3 (stopped, or no such script) and 32 (crashed) not running. 64 (unsupervised: asupervise-daemonservice whose supervisor died, its daemon perhaps still running) holds, and the reason says to check for the daemon and runrc-service <svc> zapby hand. Enabled is an entry for the service in any runlevel under/etc/runlevels, asrc-update add <svc> <runlevel>makes; a disable removes it from each runlevel it is in.
Any other exit holds the service as undetermined.
Service triggers close the config-freshness gap
Service detection checks running and enabled, not whether the service's config
is fresh. The trigger model closes that gap by reacting to changes, not by
deepening detection: a file lists triggers (push), or a service lists restart_on or reload_on (pull). When the referenced resource changes, the
planner promotes the service from skip to a restart or reload.
Holding: the undetermined verdict
When Zap cannot establish current state, it refuses to guess. The usual causes
are a probe that couldn't be reached, a remote path whose target home wasn't
declared, a path or name whose reference resolves to a value Zap won't use (one
holding a reference delimiter, a path that isn't absolute or spans lines, a database name or host a pg_hba.conf line would misread), and, in plan, detect, status and a deploy's preflight (which bails, exit 2), a tracked pin
with no lock entry or a stale one (apply mints a missing pin and refuses a
stale one, exit 2). The planner holds the
resource and does not propose a create that might clobber live state. The next
pass reads it again: no verdict carries over from one pass to the next.
A held resource contributes no change, so it simply isn't in the changeset. An empty plan would hide it, which is why every command that compares reports the hold as a verdict:
planandapply(dry or wet) show anUNDETERMINEDbanner naming each held resource and why, anddetectmarks each one?. All three exit 1: "converged" is a claim about the target that was never established for the resources nobody could read, and for a dryapply1 outranks 2 ("drifted") too.deploy's drift preflight bails (exit 2): a gate that couldn't check everything must not pass by default.
Exit codes in full: cli.md.
A hold spreads to what can't run without it
A change that waits on a held resource is held behind it: it could act on
state the held resource was meant to set up. This follows depends_on forward, including the implicit edge from a service to its trigger sources,
and passes through a converged resource unreported, since that resource
changes nothing. A change that would restart or reload a held service is held
too: its change would owe the service the action with nothing to run it, so
the action would never happen. This follows trigger edges in reverse (a
file's triggers, a service's restart_on and reload_on), and never a
plain depends_on: what a held resource depends on still runs. The two rules
repeat until nothing changes, so a source held for its trigger also holds what
waits on it, and a source that is itself a service holds its own sources.
One exception: a converged service whose only change is the restart or reload a pending marker owes waits only on what it declares, so a trigger source detection couldn't read doesn't hold it; the owed action runs. A source that changed and is held behind another still holds the service its change fires.
The banner lists the resources detection couldn't read first, then the ones held behind them, each with what it waits on or would trigger:
UNDETERMINED — 1 resource(s) could not be probed, so no change is planned for them and this changeset is partial:
web — detection failed: the unit read exited 1
their live state is unknown: neither converged nor drifted. resolve the probe failure and re-run.
held behind them — 2 resource(s) can't change until they do:
after — waits on `web`, which is held
site — would reload `web`, which is heldResolving the probe failure releases the whole chain on the next pass.
undetermined is about the observed side. Its twin on the desired side is the unverifiable verdict (env.md), where the declaration is the half
that couldn't be built.
Kinds that read a failed probe as absent
A few detectors deliberately map a failed probe to absent:
firewall, on both backends;script, when itscheckfails, and averifyassertion that fails;link,directory, andswap, for a path the login user can't reach (and, for alink, one that isn't a symlink).
For these, a probe failure almost always means "not set up yet" (the fresh
host, before the same config installs ufw or nftables), and their create steps
are idempotent and identical to their update steps, so a misread probe either
re-asserts the declared state or fails loudly at execution. The one
consequence to know: a regular file sitting where a link is declared reads absent and is replaced by the link (a directory there fails the step instead).
A file is the opposite case and holds. "Exists but unreadable" proves there
is state a create would clobber, which is the harm the hold exists to prevent.
database splits the two cases:
- No PostgreSQL reads
absent. That means nopsql, or nopostgresaccount (a client-only host). Both are checked before the query runs, in the same root environment the query runs in, so this is a detected fact. Zap's probes and steps run aspostgres, and there is no cluster they could reach, so the fresh host's first apply creates it. A cluster run under another account name readsabsenttoo; its create steps then fail loudly undersudo -u postgres. - A failed query holds. With both present, a query that fails (the server is
stopped, or
postgrescan't authenticate) reads nothing about the role, the database, or itspg_hbarules. Output that isn't the query's one row also holds, and so does apg_hba.confthe server names that thepostgresaccount can't read: the rules are read as that account, never as root.
The residual verdict
A wetrun closes the loop on the other side too. After executing, apply re-detects the target. This is the observe pass, and it becomes the last-run report. Any resource that still
doesn't read converged is reported as the residual verdict, exit 1.
A change can exit 0 without converging its resource: a script whose check still fails after run, or a mutation whose probe reads back differently. So
"applied: N ok" alone is command success, not convergence. The residual banner
is what makes the wetrun's final claim an observed fact. If the re-detection
itself fails (connectivity dropped after execution), the apply reports that and
still exits 1, because "converged" was not established.
Three cases are exempt, because "not converged" is impossible or already reported more loudly:
- a
pipeline, which detectsabsentby construction; - a change that failed, or was skipped by stop-on-first-failure, which is already the run's headline and exit 1;
- a held resource, which is already the
undeterminedverdict.
A script runs its check twice per wetrun, once detecting and once verifying
convergence. That is the price of its user-defined depth.
How detection reads the target
Every kind detects through batched queries. (A pipeline reads nothing: it
always runs.) A kind declares the reads its verdict needs as typed queries, and
a pure function turns the answers into the verdict. The function sees nothing
but those answers, so a detector can't read anything it didn't declare. The
queries are:
- the type of what a path resolves to;
- a path's own mode and ownership;
- a symlink's target;
- the SHA-512 of a file's bytes;
- the first few bytes of a small file;
- a command Zap owns: a package manager's query, a service manager's probe, an
awkmatch in/proc/swaps,ufw status verbose, apsqlquery; - an operator's command: a
script'scheck, each of averify's assertions, apackage'scheckthat is a command.
The pending-trigger markers (resources.md) are read the same way, as one more query of the plan's pass.
Every batched query is known before anything is read from the target: from the
config, zap.lock, and the operator's content cache. (An uncached remote file's
bytes are read after the batch; see below.) The two reads that depend on another
read's answer compose in the target shell, inside one command: a database's pg_hba greps run against the hba_file its catalog query just reported, and
a copy_from user's source authorized_keys is read from the home its /etc/passwd entry names.
Privilege
Every query names the privilege its read runs under. It is the privilege the equivalent single-command probe would have, so a kind sees the same thing whether it is read alone or in a batch.
- login: as the connecting user, unprivileged, on every connection. Read
this way:
- a
directory's existence and alink's target; - a package manager's query and a service manager's two probes;
- the
swapfile'stest -fand its/proc/swapsand/etc/fstabmatches; - an
apt_repo's source line, resolved against/etc/os-release; - a
user'sid -u,/etc/passwdshell and home, what is at its home and who owns it, the home-directory mode, andid -nG; - a
verify's assertions: apath_existschain oftestandstat(Zap's own command; one under a directory the connecting user can't search fails) and acommand'srun; - the other operator's commands: a
script'scheckand apackage's commandcheck; - the pending-marker read.
- a
- root: as root on every connection. It is the privilege of a probe that
carries its own
sudo, written without it. A local target runs these throughsudo. Read this way:- a
firewall's status (ufw status verbose,nft list table inet filter); - a
database's catalog query andpg_hbagreps, each run as thepostgresaccount throughsudo -u postgresfrom root; - a
user's/etc/shadowpassword field and sudoerstest -f; - a
copy_fromsource'sauthorized_keys, read as the source account throughsudo -ufrom root; - a
user's ownauthorized_keys, read as that user throughsudo -ufrom root, the account the file belongs to and is written as. A symbolic link or anything but a regular file there is not read; it drifts, and apply writes the file in its place.
- a
- elevated: as root over SSH, where the login user isn't assumed to own what
Zap manages. On a local target the read is tried as the operator first,
directly. A read that fails any way other than the path not being there (a
root-only parent, a file only root can read) is asked again as root, through
sudo, in one batch for all of them. A local config that touches only the operator's own files never invokessudo. Read this way:- every read of a
file: its existence, content, and mode, owner, and group; - a
directory's managed mode, owner, and group; - a
package's or aninstaller'scheckpath; - an
apt_repo's key and sources list; - a
build's output and provenance sidecar; - a
user's sudoers contents.
- every read of a
Because a file's content is compared by digest and not read as text, a file
whose bytes aren't valid UTF-8 reads like any other. It doesn't send a local
read to sudo.
A content read (a digest, or a bounded read of a sentinel or sudoers file) opens
only a regular file of its own. A symbolic link at the path, dangling or not, is
not followed, and a directory, a device, or a fifo is not opened: at root's
privilege, opening a device can act on the machine, and a fifo holds the read
until its timeout. The read reports the path as not a regular file instead, and
each resource says what that means: a file whose path is a symbolic link to a
regular file drifts, since its write replaces the link with the file; anything
else there that isn't a regular file holds it. A regular file with more than one
hard link, its link count read from the descriptor, is read by no content read
and holds the resource, saying to replace it with a copy of its own (a single
link): a hard link is the file under another name, which could make a root read
of the declared path read a file someone else linked there. A name swapped
between the check and the open is opened as it then is, under the read's
timeout.
Content is compared by digest
A file's content is never shipped back to compare. The target runs sha512sum over the file and only the digest crosses the wire, however large the file. What
the digest is compared against depends on the content:
inlineandtemplate: the SHA-512 of the rendered content. Inline content and a template's body are compared as declared; a template'svarshave their references resolved from the operator's environment.source: the digest of the source path, read in the same batch as the target's. A source that can't be read, or that is not a regular file of its own (a symbolic link included), holds the resource.remote,extract: none: the SHA-512 of the pinned bytes, taken from Zap's content cache (~/.zap/cache/files/<blake3>/<name>, where fetching them put them), after checking that those bytes still hash to the pin.- The pin is the declared
blake3, or else thezap.lockentry. With neither, or with a stale lock entry, the file holds inplan,detect,statusand a deploy's preflight (which bails, exit 2);applymints a missing pin and refuses a stale one (exit 2). - When the content cache doesn't hold the pinned bytes (a fresh
ZAP_HOME, another machine), Zap reads the target's bytes directly, after the batch, and compares their blake3 to the pin. Only a regular file of its own is read: a symbolic link or anything else at the path holds the resource. When they match, it stores them in the content cache, so the next pass compares digests. - Detection never fetches the url.
- The pin is the declared
remote,extract: tar.*: the extract sentinel's first 4 KiB, compared, with surrounding whitespace trimmed, to the desired provenance (the digest of the pin and theextractform). A sentinel longer than that drifts.
A drifted remote file reports what it observed: the target's blake3 when its
bytes were read, or its SHA-512 when only the digest was. The message is remote content differs from its pin (observed sha512 …), and --diff shows it
as a replacement of the content pin, not as a pin of its own.
Batches
Over SSH, all of a pass's login reads run as one shell script in one
command, and all of its root and elevated reads as a second, under sudo: the
login and root lanes. The two run concurrently. A detection pass costs one
round trip per lane, however many batched resources the config declares. A
target missing a tool a lane's batch needs, or the /proc/self/fd its content
reads check what they opened through, fails that whole lane.
Connectivity. The batches are also the pass's connectivity check. An ssh that can't reach the target (it exits 255, or can't start) fails the command
with a cannot connect error before any verdict is printed. The error carries ssh's own message, with a hint when the host key changed. A pass with nothing
to batch checks connectivity with an ssh true of its own.
One authentication. A pass authenticates once. When no connection to the
target is already open for Zap to share (its ControlMaster), the login batch
waits for the root batch to be under way and shares the connection it opened,
so no second one is opened.
What adds a round. Two things:
- an unextracted
remotefile whose pinned bytes aren't in the content cache. Its bytes are read with a command of their own, after the batch. See content is compared by digest. - on a local target, the elevated reads the operator was denied. A local target
answers login and elevated path reads natively, without a shell, and asks the
denied elevated ones again as root in one
sudobatch after that. Its login command reads run as one local batch, as the operator, and its root reads run in the root batch.
Local sudo. A local root batch runs under sudo -n, after Zap has made
sure sudo won't prompt; operator commands run with no terminal, locally as
over SSH (targets.md). If sudo won't authenticate,
every resource with a read in that batch holds, with the fixed reason sudo authentication failed.
The script travels on stdin, never the argv, and each probe's output comes
back framed, so bytes a probe prints (newlines and NULs included) can't be
mistaken for another probe's answer, and nothing a probe prints reaches the
stderr Zap shows when ssh itself fails. A probe's exit status travels apart
from its output, so nothing the probe writes can forge it; a command runs in a
session and process group of its own, so a command that signals its group
reaches only itself. The target's sudo log records one constant wrapper per
lane (security.md).
Locale. Zap's own probes run under LC_ALL=C, so what they print parses the
same on every target. An operator's command runs in the target's own locale, as
it would by hand.
Operator commands (a script's check with its references resolved, its cwd and env) travel inside the script too, so a resolved value is on no
argv. The check's exit decides: 0 is applied, anything else (a cwd that can't
be entered and a check that doesn't parse included) is absent. The script
holds only when the check produced no exit status (a timeout, a failed lane) or
a reference can't be carried.
What the target needs. timeout, od, tr, head, cat, stat, and readlink; sha512sum when the batch reads a digest; ls when it reads
whether an extracted tree is empty; and setsid and sleep when it runs a
command. Over SSH, setsid is needed in any case. These come
from GNU coreutils and util-linux, or BusyBox (validated on 1.37). A target missing
one fails the batch, and does not read every path as absent. Writes need a few
more (targets.md); a write that lacks one
refuses, naming it, with nothing changed.
Output limit. Zap reads at most 64 MiB of a batch's output. Past that it stops the batch and holds every resource in it.
Local content reads. A local target answers content reads natively too, except of something that is neither a regular file nor a directory (a fifo, a device), which its probe answers under its timeout.
What a read can conclude
Commands answer with their exit status.
- An operator's command: a
script's orpackage'scheckexiting 0 is applied or installed, and anything else is not. Averifyassertion holds when its command exits 0 and, with anexpect, its stdout contains it. - A command Zap owns: the package manager's query and the service manager's
probes exit 0 if and only if the state holds;
test -f, the swap'sawkmatch, and apg_hbagrepexit 0 if and only if the file or line is there. The package manager's query answers "not installed" with exit 1 alone: any other status is a query that failed (dpkg-queryunable to read its database, or missing), and the package holds.
Some commands also print what they read (a firewall's ruleset, a user's shell or
groups, a database's catalog row), which the verdict parses. A failure means
what it meant to that probe: a firewall probe that fails needs setting up, and a user attribute probe that fails holds the user.
Otherwise, what holds is an answer that is missing (a timeout, a garbled record,
a failed batch) or output that doesn't parse. A database also holds when its
catalog query failed with PostgreSQL installed, or when its pg_hba.conf couldn't be read.
A path read concludes "nothing there" from one failure only. That is the
failure stat and readlink report for it (their exit status 1), and the
content reads, which open the path with a redirect that exits 1 when it can't be
opened. It means the path is absent, can't be reached at the read's privilege,
or, for a symlink's target, isn't a symlink. For a content read, it means the
path can't be read (a directory); for an extracted tree's emptiness read, that
it isn't a directory the read's privilege can list. A file that exists but whose content read
finds nothing holds, and does not read absent.
Anything else holds the resource as undetermined and does not guess:
- a probe that failed another way (it couldn't run, or was killed);
- output that doesn't parse (an owner or group name containing whitespace);
- a missing or garbled answer.
A resource needs only the answers its verdict reads: the mode of a directory
that doesn't exist is never consulted. But a batch that failed as a whole holds
every resource with a read in it, with a fixed reason that quotes none of the
batch's output. That covers a dropped connection, a refused sudo, output that
stopped before its end marker, and a missing tool. So does a pass whose answers
don't line up with its reads one for one (a layout mismatch): no answer in it is
read, and the reason names the read left without one. None of these ever reads
as absent.
Timeouts
Each probe runs under timeout and reads undetermined ("timed out after 10s")
when it runs out:
| Read | Limit | Why |
|---|---|---|
| Zap's own path reads | 10 seconds | a healthy stat takes milliseconds; a slow one is a hung mount |
| the mode and ownership read | 30 seconds | it resolves owner and group names, which may go through a network name service |
| a digest | 60 seconds | it reads the whole file |
| a command, Zap's own or an operator's | 60 seconds | it may do real work |
| a database's catalog query, inside its command's limit | 50 seconds | its own timeout stops psql, and leaves the pg_hba greps room |
Zap's own commands are a package manager's query, a service manager's probe, a
firewall's status, and a database's catalog query with its pg_hba greps.
A command's timeout stops its whole process group, children included, with SIGTERM and then SIGKILL a second later. A command that exits 124 or 143 on
its own reads as that exit; what reads as timed out is a command with no
report. Because something can outlive a probe's timeout (a process that left
the command's group, or one a command backgrounds), each batch also runs under a
deadline, the sum of its probes' limits plus a margin. When that passes, Zap
stops the batch, kills what it can of it on the target, and holds every resource
with a read in it, so a stuck probe costs at most the deadline.
Nothing is cached
Every pass reads every resource. A stored answer is sound only when nothing outside Zap could have changed the target since it was read, and Zap never assumes that; and a pass costs one round trip per lane however many resources the config declares, so a stored answer would save nothing.
That matters most for the controls people change by hand: security.md.
The pending-trigger markers (/var/lib/zap/pending/) follow the same rule:
they are read on every plan for a config that declares a trigger, in the same
batch as the plan's detection reads
(resources.md).
The caches Zap does keep are on the operator machine and never stand in for the target: the config eval cache, whose manifest is validated again on every lookup, and the content cache, whose bytes are checked against their blake3 on every read (lock.md).
The last-run report
Every detection pass records what it read in a last-run report, and zap status renders it offline. The passes that write it:
detectandplan;apply: a dry run's pass; a wetrun's pass before its changes, then its re-detection after them, which is the last word; and a refused wetrun's pass, recorded as the preview it was;deploy's drift preflight.
Detection never reads it. The wetrun's review gate reads only which pass wrote
it and its plan_hash.
The report is latest-only, one file per config and host, under ~/.zap (lock.md). It holds:
- when the pass ran (RFC 3339, UTC), which command pass it was, and the host
(
local, or the resolved remote host); - the names of any references the pass's comparison couldn't resolve,
names only. This is the pass's
unverifiablefinding. - per resource read: its verdict (
converged,drifted,absent, orundetermined), its hash, and whether it references a secret. It never holds the observed state, anundeterminedreason, or anything else the target returned. - the pass's batches: each one's lane and query count;
- the
plan_hashof the plan the pass computed, when it planned (detectdoesn't). For a pass that previewed that plan (zap plan, a dryzap apply, a refused wetrun), this is the reviewed plan the nextzap apply --wetrunbinds to (concepts.md).
What a resource's hash covers
A resource's hash covers its declaration as written, less its identity (id, name, depends_on) and a fetch credential; the value of each reference in a
field that takes one; and the pin zap.lock holds for it. A reference resolves
in one of two ways:
- a secret one (env.md) resolves to a fixed
[MASKED:NAME]token, so aDATABASE_URLholding a password hashes as its own token; - any other resolves to its value, with every registered secret in that value masked.
So a changed value of an ordinary variable, wherever the resource takes a
reference (a script's check, a path, a template var), reads as a changed
config. A verify command is hashed as written.
What the hash excludes is the value of every registered secret, whether referenced by name or carried inside another variable. So the report can't be used to test a guess at one, and a changed secret value can't be seen offline. There is one exception to the first: a short secret that also happens to occur inside a known non-secret value, where whether the masker masked that value depends on the secret.
A value the masker doesn't know as a secret is hashed as it is. That means an unregistered name, or text written literally into the config, which the secret gate rejects when it can.
The host a report is filed under is always a resolved value: a host that
resolves through a masked reference keeps no report, every pass says so
(UNRECORDED), and zap status reports that none is kept and exits 1. The
report also records a target hash over the whole zap.target(...) (host,
users, port, key path, home, package and service manager), its references
resolved the way a resource's are, since every verdict was read against it. A
file that isn't a report of the current format version reads as no last run.
Glossary #
One line per term, with where it's explained. The model itself is in concepts.md.
Absent — a detection verdict: the resource isn't present; the planner creates it. concepts.md
Additive — a declared list where each entry must be present and an entry
added by hand is neither detected nor removed: firewall allow rules, pg_hba rules, a user's groups. concepts.md
Apply — zap apply: a dry run of the plan by default; with --wetrun, the
command that changes the machine. cli.md
Authoritative — a field made exactly the declared value; anything added by hand is removed, not merged. One of three field classes, beside additive and unset. concepts.md
Batch — one shell script per lane carrying every detection read of a pass, delivered on stdin, run once per pass. detection.md
Bootstrap user — target.bootstrap_user, the alternate account --bootstrap connects as for a first provisioning run before SSH hardening
locks it out. targets.md
Build — the build resource: fetch a source archive, verify and pin it,
build it, record its provenance in a sidecar. resources.md
Changeset — the resources the plan will create or update, with their steps.
What plan_hash names. concepts.md
Converged — a detection verdict: the desired state is already present; nothing to do. Also the state of a machine whose every resource reads so. concepts.md
Convergent — the control model: read the current state, diff it against the declared state, change only the difference. Re-running is always safe. concepts.md
Declaration — the JSON a config evaluates to: the target plus every
resource. A pure function of zap.ts and the files it reads. configs.md
Deploy — zap deploy: ship a versioned release to a remote host as releases/<version> plus a current symlink, restart, health-check, roll back
on failure. The one imperative command. deploy.md
Depth — how much detection reads for a kind: existence, attributes, or content. Matches the kind's execution idempotency. detection.md
Drift — the gap between the live machine and the declaration. Found by reading, every run; never tracked. concepts.md
Drifted — a detection verdict: present but wrong, carrying what was observed; the planner updates it. concepts.md
Dry run — apply or deploy without --wetrun: reads the target, renders
the plan, changes nothing on it. concepts.md
Elevated — a detection privilege: root over SSH; on a local target, the
operator first and sudo for a read that failed. detection.md
Env file — a file of NAME=VALUE lines loaded with --env-file, the source of
reference values alongside the environment. env.md
Extract sentinel — the .zap_remote_hash file an extracted remote file
leaves in its destination, recording a digest of the tarball pin and the extract
form. lock.md
Gates — the machine-free checks every command runs on the declaration: secrets and structure. configs.md
Held — what happens to an undetermined resource: left out of the plan, reported, exit 1. A change that depends on a held resource, or would trigger a held service, is held behind it. concepts.md
Imperative — a resource or command that always runs because there is no
stable state to converge to: pipeline, deploy. concepts.md
Lane — one of a pass's batches, named for what it runs as: login, or root. A lane is not a privilege: the root lane carries both the root and the elevated reads. detection.md
Last-run report — what the most recent detection pass on a host read, kept
under ~/.zap and rendered by zap status; a preview's report holds the plan a
wetrun binds to. detection.md
Lock standing — a tracked pin's classification against the config: current, missing, stale, unverifiable, or orphan. lock.md
Login — a detection privilege: the connecting user, unprivileged. detection.md
Mask — removing a resolved secret's value from every output. zap.mask() / zap.unmask() say so for a reference the rule reads wrongly. env.md
Operator — you: the person (or agent) running Zap, whose environment supplies reference values and whose SSH keys reach the target.
Orphan — a last-run resource, or a lock entry, that the config no longer declares. Warn-only. lock.md
Pass — one detection of every declared resource against the target: every read batched, every resource read live. detection.md
Pending trigger — a marker under /var/lib/zap/pending/ on the target
recording a restart or reload a run owes a service. concepts.md
Pin — the blake3 of fetched bytes, declared in the config (vendored) or
recorded in zap.lock (tracked). lock.md
Plan — the changeset for one run against one target, and the command that renders it. concepts.md
plan_hash — the blake3 that names a plan for review and for --expect-plan. It covers the changes, the machine they are for, what each
reference resolves to (a secret only as the fact), and the text Zap's own steps
run. concepts.md
Preflight — the convergence pass deploy --wetrun runs before shipping;
drift or an unreadable resource refuses the release. deploy.md
Preview — a pass that rendered a plan and executed nothing: zap plan, a
dry zap apply, a refused wetrun. Its plan is the one a wetrun binds to by
default. zap deploy keeps its own, the deploy preview (deploy.md). concepts.md
Provenance sidecar — the <output>.zap-build file recording a digest of the
source pin a build came from and how it was built. lock.md
Recipe — a shipped pure function returning a config fragment. recipes.md
Reference — zap.env('NAME'): a stand-in for an operator-environment
value, inert in the declaration, resolved when Zap acts, shown as {env:NAME}. env.md
Refused — a wetrun that didn't execute because its plan wasn't the reviewed one, or, asked on a terminal, wasn't confirmed (declined). Exit 2; the plan is rendered and becomes the preview. concepts.md
Residual — a resource that still doesn't read converged after a wetrun's changes ran. Exit 1. concepts.md
Resource — one typed unit of desired state with a stable id. resources.md
Reviewed plan — the plan a wetrun must match to execute: the one named by --expect-plan <hash>, or the one in the host's last-run report when that pass
was a preview. concepts.md
Root — a detection privilege: root on every connection. detection.md
Target — where a config acts: local: true, or a host over SSH, with its
package and service managers. targets.md
Template kind — the form a template variable's value is held to
(hostname, port, path, …), checked on its whole rendered value: a hostname in an nginx server_name can't end the directive. resources.md
Toolchain pin — the fgro build a config is evaluated with, recorded in zap.lock by zap lock; a missing or mismatched pin refuses. lock.md
Tracked — a remote file, build source, or apt_repo signing key with no
pin in the config; the pin lives in zap.lock. lock.md
Trigger — a file's triggers, or a service's restart_on / reload_on: a change in the source promotes the service to a restart or
reload. concepts.md
Two-tier privilege escalation — privileged commands carry an explicit sudo; on a local target file operations try natively and escalate only when
denied, and over SSH they run under sudo. targets.md
Unbindable — a plan whose host, connecting user, or deploy health domain
resolves through a masked reference: it has no plan_hash, so no wetrun can
bind to it. env.md
Undetermined — a detection verdict: Zap couldn't read the resource and won't guess. Held. concepts.md
Unset — a field the config leaves out: unmanaged, neither set nor reverted. concepts.md
Unverifiable — a comparing pass whose desired side depended on an unset reference. Exit 1. env.md
Vendored — a remote file, build source, or apt_repo signing key
pinned by a blake3 in the config itself (an apt_repo's key_blake3). No
lock entry. lock.md
Verdict — detection's answer per resource: converged, drifted, absent, or undetermined. concepts.md
Wetrun — --wetrun: the flag that makes apply or deploy act. Zap's
word for "not a dry run", chosen to be hard to type by accident. concepts.md
Withheld — a tracked pin that isn't current, dropped before detection by the read-only commands so its resource holds. lock.md
zap.lock — the pin ledger next to zap.ts, committed with it. lock.md
~/.zap — the operator-side state directory (or $ZAP_HOME): caches,
last-run reports, deploy previews, SSH sockets; the installed binary stays in ~/.zap/bin. Never part of the plan. lock.md
Contributing #
How to build, check, and test Zap when you are changing it. The project-wide guidelines and code of conduct are at fuz.dev/contributing. To install Zap for use, see install.md.
Builds
| Command | Produces |
|---|---|
cargo xtask install | a release build at ~/.zap/bin/zap, the binary on your PATH |
cargo build -p zap | a debug binary at target/debug/zap |
cargo xtask build-musl | static binaries for x86_64 and aarch64 Linux at target/<triple>/release/zap, with no glibc dependency (needs zig and cargo-zigbuild); evaluating a config still needs an fgro that runs on that machine |
Checks
cargo clippy --workspace --all-targets -- -D warnings # the engine's lints
cargo test --workspace # the engine's suite
gro check # the site and the TypeScript surface
cargo fmt -p zap -p zap_core -p zap_types -p xtask # format the engine
cargo xtask check-release # production binaries link no test or audit crate
RUSTDOCFLAGS='-D warnings' cargo doc --workspace --no-deps --all-features --document-private-items # the docs buildCI runs each of these as a gate, a warning from clippy or rustdoc included, and
runs the suite a second time under umask 000, since a mode the tests assert is
only as strict as the umask their files are made under.
Run npm ci before gro check. After an engine change, run cargo xtask install: the zap on your PATH is whatever was last installed,
and a stale one mis-plans in ways that look like config bugs.
Format with the packages named, never cargo fmt --all: rustfmt follows the
path dependencies into the sibling fuz checkout and would reformat its files.
The hermetic suites
Nothing in them reaches a remote host. A test that runs an external program
checks nothing where the program is missing, so each crate's required_programs test fails there instead, unless ZAP_TEST_ALLOW_NO_<NAME>=1 says the absence is expected:
| Crate | Programs |
|---|---|
zap | fgro (ZAP_FGRO), git |
zap_core | fgro (ZAP_FGRO), busybox, git, setfacl, getfacl |
xtask | openssl, curl, busybox |
fgro is the one not looked up on PATH: the tests that evaluate a config run
the build ZAP_FGRO names, by absolute path
(ZAP_FGRO=/abs/path/to/fgro cargo test --workspace). The site's zap config validate e2e needs ZAP_FGRO and a built target/debug/zap (cargo build -p zap), and skips without them. Legs that need fakeroot run
only where it is installed; a missing one is noted on stderr (--nocapture) and
the test passes.
CI has no fgro build to give the suite, so the tests that evaluate a config,
and the e2e, don't run there: CI sets ZAP_TEST_ALLOW_NO_FGRO=1, and the e2e
reports itself skipped. Run them locally with ZAP_FGRO set before you push a
change to config evaluation.
Validating against real machines is separate: testing.md for the VMs, differential.md for the harness that compares two zap builds on them.
Adding a resource kind
A new kind touches every crate; the module maps are crates/zap_types/CLAUDE.md and crates/zap_core/CLAUDE.md.
- Schema: in
crates/zap_types/src/schema/, theResourceKindvariant (mod.rs) and its payload struct (resources.rs).tests/fidelity.rsguards the round trip. - Detection:
crates/zap_types/src/observe/<kind>.rs, typed read-onlyqueriesand a pureinterpretreturning aDetection, wired inObserved::for_resource(crates/zap_core/src/resource/detect/observed.rs; the match is exhaustive, so a missing arm fails to compile). Every kind is read live on every pass. - Execution: the kind's arm in
change_commands(crates/zap_core/src/resource/execute.rs; the match is exhaustive), whichrun_change's match dispatches on: a kind with command steps runs them, a native one gets an arm inrun_native, andfileandbuildhave their own executors. - Steps and hashing:
crates/zap_types/src/plan/steps/<kind>.rs, with inline command goldens, and the field selection incrates/zap_types/src/config_hash.rs. - Builder:
src/lib/zap.ts,src/lib/schema.ts, and the driver (crates/zap_core/src/config/builder_fgro.js). Mirror the change inbuilder.jsbeside it; the drift tests fail otherwise. - Tests: detection goldens (
crates/zap_core/tests/fixtures/detection/) and the fidelity round trip. - Docs: the kind's entry in resources.md.
Recipes
The shipped recipes follow the design rules in recipes.md.
Testing against real targets #
A contributor's page: how to validate Zap against a real target. For a first run against your own machine or a server, see getting-started.md.
This page covers what the hermetic suites (contributing.md)
can't: confirming that the generated commands
and detection probes behave on a live machine. It uses two local VMs, Alpine (apk and OpenRC) and Debian (apt and systemd), driven over SSH from a
glibc dev zap, by hand or through the differential
harness. They are VMs, not
containers, because service, firewall, swap, and user need their own
init, netfilter, and kernel, and the wetruns below are destructive.
Prerequisites
A hypervisor with KVM (/dev/kvm present). The commands below use libvirt and virt-install, which gives the VM a host-reachable 192.168.122.x address so
the throwaway config works unmodified:
sudo apt install -y virtinst libvirt-daemon-system qemu-system-x86 qemu-utils
sudo systemctl enable --now libvirtd
sudo virsh net-start default; sudo virsh net-autostart default # NAT network up
sudo adduser "$USER" libvirt # reach qemu:///system without a polkit password promptMembership in libvirt is what lets the unprivileged virsh -c qemu:///system commands below, and the differential harness, run without a password. Debian's 60-libvirt.rules polkit rule grants org.libvirt.unix.manage to that group.
It is effectively root, since a member can define a VM that attaches host disks.
Grant it only on a dev machine where that's acceptable.
The Alpine VM
A small mixed config (an apk package, an OpenRC service, and a file) driven
through detect, a dry apply, apply --wetrun, and apply again.
1. Bring up the VM
Download the latest "virt" ISO into the libvirt image pool. The system
libvirt runs QEMU as libvirt-qemu, which can't read a 0750 home directory,
so the ISO must not live under ~:
base=https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/x86_64
iso=$(curl -s $base/ | grep -o 'alpine-virt-[0-9.]*-x86_64.iso' | sort -u | head -1)
sudo curl -fL -o /var/lib/libvirt/images/alpine-virt.iso "$base/$iso"Create and install. This drops you into the serial console:
sudo virt-install --name alpine-zap --memory 1024 --vcpus 2 \
--disk size=4,format=qcow2 \
--cdrom /var/lib/libvirt/images/alpine-virt.iso \
--osinfo generic --network network=default \
--graphics none --console pty,target_type=serialAt localhost login: log in as root (no password) and run setup-alpine. The
answers that matter:
| Prompt | Answer | Why |
|---|---|---|
| NTP client | none | keeps chrony uninstalled, so the package step is a real test |
| APK mirror | f | the fastest mirror |
| user | ops | you'll add it to wheel next |
| SSH server | openssh | |
| disk | sda, mode sys | a full install to disk. The disk enumerates as sda, not vda, because --osinfo generic uses the SATA bus. That's fine. |
Then reboot. It should come up as alpine-zap login:. From now on it's sudo virsh start alpine-zap and sudo virsh console alpine-zap (Ctrl-] exits
the console). Get its address with sudo virsh domifaddr alpine-zap.
setup-alpine configures the interface for DHCP, so the address is whatever
libvirt's default network leases it. The differential harness expects it at 192.168.122.10. Pin it with a DHCP host reservation for
the VM's MAC, so a lease expiry or a rebuilt network can't move it. <mac> is
the MAC column of virsh -c qemu:///system domiflist alpine-zap:
virsh -c qemu:///system net-update default add ip-dhcp-host \
"<host mac='<mac>' name='alpine-zap' ip='192.168.122.10'/>" --live --config2. Make it zap-drivable
Zap shells out to sudo (not doas) and runs it non-interactively over
SSH, so the target needs sudo installed and a NOPASSWD sudoer. On Alpine, sudo lives in the community repo, and so does chrony:
# in the VM, as root:
setup-apkrepos -c -f # enable the community repo + fastest mirror
apk update && apk add sudo
adduser ops wheel
echo '%wheel ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/wheel
chmod 0440 /etc/sudoers.d/wheelInstall your host key so Zap connects without a prompt:
# on the host:
ssh-copy-id ops@<vm-ip>
ssh ops@<vm-ip> true # must return silently3. Run Zap against it
Save this throwaway config as zap.ts in a directory of its own, beside a package.json such as {"name": "zap-alpine-probe", "type": "module"}: a
config is evaluated in its project, which a package.json marks. It needs no
recipes. host and user are references, resolved from the operator env at
connect time:
// zap.ts
export default (zap) => {
zap.target({
host: zap.env('ALPINE_HOST'),
user: zap.env('ALPINE_USER'),
package_manager: "apk",
service_manager: "openrc",
});
zap.pkg({ id: "chrony", name: "chrony", from: { apk: "chrony" } });
zap.service({
id: "chronyd",
name: "chronyd",
service: "chronyd",
enabled: true,
running: true,
depends_on: ["chrony"],
});
zap.file({
id: "zap-probe-file",
name: "zap probe file",
path: "/etc/zap-alpine-probe",
content: { type: "inline", content: "zap alpine validation\n" },
mode: "644",
});
};Drive it, with ZAP_FGRO set to your fgro copy
(install.md). The env prefix works in every shell; in bash or zsh you can also
write the assignments inline, as ALPINE_HOST=… ALPINE_USER=… zap …:
env ALPINE_HOST=<vm-ip> ALPINE_USER=ops zap lock # pin the fgro build ZAP_FGRO names
env ALPINE_HOST=<vm-ip> ALPINE_USER=ops zap detect # probe sanity
env ALPINE_HOST=<vm-ip> ALPINE_USER=ops zap apply # dry-run: see the plan
env ALPINE_HOST=<vm-ip> ALPINE_USER=ops zap apply --wetrun # converge
env ALPINE_HOST=<vm-ip> ALPINE_USER=ops zap apply # idempotency re-checkExpected:
detectshowschronyabsent andchronyddrifted (running=false, enabled=false);- the dry run plans a
cache-update-apkchange (apk update) ahead ofapk add chrony,rc-update add chronyd defaultandrc-service chronyd start(inside thesh -cthat resets a crashed service first), and the file; - the wetrun reports
applied: 4 ok, 0 failed, 0 skipped (of 4); - the final run reports
(no changes — converged): the probes (apk info -e,rc-service <svc> status, the service's entry under/etc/runlevels) read the post-apply state correctly, as the wetrun's own re-read already checked (aRESIDUALbanner names anything that doesn't read converged).
The Debian VM
debian-zap is the apt, systemd, and ufw twin of alpine-zap: Debian 13's
generic cloud image, configured on first boot by cloud-init from a NoCloud seed,
at the static address 192.168.122.11. There is no installer to click through.
The image boots straight into a configured system.
1. Fetch and verify the cloud image
base=https://cloud.debian.org/images/cloud/trixie/latest
curl -fLO $base/debian-13-genericcloud-amd64.qcow2
curl -fLO $base/SHA512SUMS
sha512sum --ignore-missing -c SHA512SUMS # must print "debian-13-genericcloud-amd64.qcow2: OK"
qemu-img resize debian-13-genericcloud-amd64.qcow2 8G # cloud-init grows / on first boot2. Write the cloud-init seed
Three files. user-data creates ops with NOPASSWD sudo and your public key.
Zap runs sudo non-interactively, the same requirement as on Alpine:
#cloud-config
hostname: debian-zap
users:
- name: ops
groups: [sudo]
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: true
ssh_authorized_keys:
- ssh-ed25519 AAAA… you@host
ssh_pwauth: false
package_update: falsemeta-data names the instance:
instance-id: debian-zap
local-hostname: debian-zapnetwork-config pins the address. A static IP keeps the configs and the harness
unmodified across rebuilds:
version: 2
ethernets:
primary:
match:
name: "en*"
addresses: [192.168.122.11/24]
routes:
- to: default
via: 192.168.122.1
nameservers:
addresses: [192.168.122.1]Pack them into an ISO labeled cidata. The label is how cloud-init finds a
NoCloud seed:
xorriso -as mkisofs -output debian-zap-seed.iso -volid cidata -joliet -rock \
user-data meta-data network-config3. Upload both into the default pool
Upload through libvirt; don't copy into /var/lib/libvirt/images/. The daemon
writes the files with the ownership QEMU needs, so it works without root, and
nothing lives under ~ where libvirt-qemu can't read it (the Alpine ISO
gotcha):
v="virsh -c qemu:///system"
$v vol-create-as default debian-zap.qcow2 8G --format qcow2
$v vol-upload --pool default debian-zap.qcow2 debian-13-genericcloud-amd64.qcow2
$v vol-create-as default debian-zap-seed.iso $(stat -c %s debian-zap-seed.iso) --format raw
$v vol-upload --pool default debian-zap-seed.iso debian-zap-seed.iso4. Create the domain
virt-install --connect qemu:///system --name debian-zap --memory 1024 --vcpus 2 \
--disk vol=default/debian-zap.qcow2,bus=virtio \
--disk vol=default/debian-zap-seed.iso,device=cdrom \
--import --osinfo debian13 --network network=default \
--graphics none --video vga --console pty,target_type=serial --noautoconsole--video vga is required. With --graphics none alone the domain gets no
video device, and this image's GRUB reboot-loops without one, never reaching the
kernel. A VGA device with no graphics backend attached is invisible but
satisfies GRUB. The serial console still works (virsh console debian-zap).
First boot runs cloud-init. Then ssh ops@192.168.122.11 true must return silently. The address is static in the
guest, but reserve it on the libvirt side too, so the DHCP pool never leases it
to another domain. <mac> comes from virsh -c qemu:///system domiflist debian-zap:
virsh -c qemu:///system net-update default add ip-dhcp-host \
"<host mac='<mac>' name='debian-zap' ip='192.168.122.11'/>" --live --configThe image ships without ufw. A config that uses the ufw firewall backend
installs it first, with zap.pkg.
5. Snapshot both VMs
The differential harness starts every base build from a snapshot named pristine. It never modifies or deletes it:
virsh -c qemu:///system snapshot-create-as debian-zap pristine
virsh -c qemu:///system snapshot-create-as alpine-zap pristineGotchas
virt-install: Could not open '…/alpine-virt.iso': Permission denied. The
ISO is under ~, which libvirt-qemu can't read. Keep it in /var/lib/libvirt/images/.
Every virsh -c qemu:///system call prompts for a password (System policy prevents management of local virtualized systems). Your user
isn't in the libvirt group, so polkit falls back to admin auth. Run sudo adduser "$USER" libvirt. polkit looks up group membership itself, so it
usually applies without logging in again; log out and back in if it still
prompts.
Guest udhcpc: no lease or Network unreachable. A restrictive host
firewall (nftables input and forward default-drop) drops DHCP and forwarding
on virbr0. libvirt's own accepts live in a separate table and can't override a
sibling base-chain drop. Add iifname "virbr0" accept to your input and forward chains, for example sudo nft insert rule inet filter input iifname "virbr0" accept. That is
runtime-only, so make it durable in your firewall config.
Guest udhcpc: sendto: Network is down. The interface is down. Run ip link set eth0 up before udhcpc. This is only relevant when testing before setup-alpine.
Console blank after virsh console. The login prompt already printed; press
Enter. If virsh start boots a blank disk, it hangs on the unseen VGA console:
boot from the install media or check virsh domblklist. Don't recycle the
libvirt network while the VM runs, because it detaches the tap and virbr0 goes NO-CARRIER.
The Debian domain reboot-loops in GRUB right after virt-install. The
domain has no video device. Recreate it with --video vga, keeping --graphics none. See the Debian VM section.
apt-get update fails after a snapshot revert with Signature … was created after the --not-after date. The guest resumed with the
clock it had when the snapshot was taken. Set it from the host with ssh ops@<vm-ip> "sudo date -u -s @$(date +%s)". The differential harness does
this after every revert.
The Alpine VM comes up on a different address after a rebuild or a long
shutdown. setup-alpine uses DHCP. Add the host reservation from the Alpine
section.
The first connection after a snapshot revert takes a few seconds. The resumed guest drops packets briefly; it is not ssh. Poll with a short ping before timing anything.
Staging a remote file or build archive relies on a target-side mktemp -d '/tmp/zap-…-XXXXXX'. BusyBox mktemp supports both -d and a
template ending in XXXXXX, so this works on stock Alpine. Confirm it on the VM
when changing an Alpine tar extract or build path.
The differential harness #
cargo xtask differential runs two zap binaries against identical states of
the two VMs in testing.md and fails if they report anything differently. One is a trusted oracle, by default a release build of origin/main. The other is a candidate, by default the working tree.
It proves behavioral equivalence, not correctness: a refactor that passes it changed nothing an operator can observe, across real apt, systemd, and ufw hosts and real apk, OpenRC, and nftables hosts, over SSH and (on Debian) through the local connection. What it establishes about the candidate alone, whatever the oracle says:
- with
--base-by candidate, that the candidate converges the whole kitchen sink frompristinethrough its own review gate, in the fixture's rounds, and that a further apply then plans nothing but apipeline; - that it puts back the drifted state of every scenario that reconverges, and that a check which never goes through Zap then reads the machine as it did on the base;
- that it can replace a host's bootstrap user in place, and plan as the user that replaced it (Replacing the bootstrap user);
- that its plans of one target state agree with each other and name the machine they are for;
- that nothing it prints holds a raw control character.
The mechanics are in the module docs, starting at crates/xtask/src/differential/mod.rs; cargo xtask differential --help summarizes them.
What it runs
1. Base. From pristine, one of the two binaries (--base-by, the oracle
by default) converges a kitchen-sink config, crates/xtask/fixtures/differential/<distro>/zap.ts, the way an operator does:
a dry apply, then apply --wetrun bound to that preview, in the rounds the
fixture declares (a fixture may declare resources held from pristine, such as
Alpine's database before its service starts). Whichever side builds is held
to that declaration, so an oracle that reads pristine differently can't build
the base; build with --base-by candidate. A last dry apply must then
plan nothing but a pipeline. The config holds every resource kind that
converges on that distro, with edge cases: paths with spaces and quotes, content
with no final newline or with CRLF endings, script checks that read stdin, run
slowly, or carry a reference, tracked and vendored pins, a triggered service, a
firewall that allows 22/tcp (the harness's own connection is the lockout
test), and privilege canaries that read ok only when a probe runs in its
expected lane.
The result is snapshotted as differential-base and reused until a hash of its
inputs changes (the pristine snapshot, the fixtures, the src/lib modules,
the distro's preparation, the test CA, the builder, and the fgro build). --rebase forces a
rebuild.
2. Scenarios. For each scenario, the harness reverts to the base and puts
the target in the scenario's state: a root shell mutation (a same-size edit, a chmod, a removed package, a stopped service, swapoff, a flipped firewall
policy, a dropped pg_hba line, a pending-trigger marker), an operator-side
change (a zap.lock entry dropped or made stale, an env var changed or unset),
or both. Then it runs both binaries, each with its own fresh ZAP_HOME and its
own staged copy of the fixtures, over detect --no-cache, plan --no-cache --diff, and plan --no-cache. Scenarios with remote files
run twice, with a cold and a warm content cache. One scenario, bootstrap-replace, is of its own kind: it compares nothing
(Replacing the bootstrap user).
3. Reconverge. A scenario that drifts declared state Zap can put back in one
offline round carries a probe: a root shell snippet that never goes through
Zap and prints the state the scenario touches (the firewall's policy and rules,
as the kernel holds them and as their files say, a user's password field and authorized_keys, the database's pg_hba rules), read where it is in force
rather than only where detection reads it.
The probe reads the base before the mutation and the target right after it,
where it must read differently, or the drift didn't take. Once the read-only
runs are compared and audited, the candidate alone, with its own staged tree and
a fresh ZAP_HOME, applies the drifted state back the way an operator does: a
dry apply that must plan exactly the scenario's expected resources and nothing
else but a pipeline, then apply --wetrun bound to it, which must complete
every change and run the reviewed plan_hash. A further dry apply must then
plan nothing but a pipeline, detect must read every resource but the pipeline ok (a pipeline reads absent by design), and the
probe must read what it read on the base. The probe is the check that isn't
circular: a detector that misreads wrong state as converged passes Zap's own
checks and fails it. The candidate alone applies because the read-only phase has
already compared its plan of that state with the oracle's; executing it is the
candidate's own property.
4. Remote content is generated deterministically and served from inside
xtask: plain HTTP on loopback for the Zap runs on your machine, and an https
origin on the libvirt gateway address for everything a guest fetches, under a
test CA the harness makes once with openssl and keeps in ~/.cache/zap-differential/pki. The CA is name-constrained to the names the
origin serves, and only the differential-base snapshots trust it, never your
machine or pristine. There is no external fetch.
5. Locks. Each binary mints its own zap.lock with zap lock, and the two
are compared: a different version, toolchain pin, or entry pin is an ERROR;
the same pins with a different config_hash are a divergence under --compare exact and only reported under --compare semantic.
6. Local suite (Debian only). Both binaries, and the fgro build, are
copied to the VM and run there as ops, with --fgro, against a local: true fixture (debian-local/zap.ts), covering
the local connection's native reads and its sudo fallback.
Each binary's fixtures, and the local suite's bundle on the VM, are staged as a
project of their own: a package.json at the root and the src/lib modules
copied in, so the configs' project, and the box they evaluate in, is that tree. A
run's scratch tree is a directory under ~/.cache/zap-differential/runs, named
for its process. It is removed when the run ends, and a killed run's is removed
by the next run.
Each VM is left at differential-base afterwards, or at pristine if no base
could be built. A run writes nothing into the repo.
What makes a scenario count
Two binaries that fail the same way would match, so a compared scenario is an ERROR, and the run fails, unless:
- the oracle's output is a well-formed report: exit 0 or 1, a
detectthat reports every fixture resource, and aplan_hash:line on each plan; - no run timed out;
- the oracle's
detectmoved exactly the resources the scenario expects, with their marks (some expectations pin a known oracle defect; the scenario table labels each); - the runs were read-only and network-free: neither listener saw a connection,
no file changed under the audited roots (
/etc,/opt,/home,/root,/srv,/usr,/lib,/var/lib, any regular file directly under/, and/itself), and the runtime state no file shows (the firewall ruleset with handles, running services with their identity,/proc/swaps, the postgres roles and databases) reads the same. Only proven churn is excluded, and the audit must show its own anchors (the fixture's sleeper service, thepostgresrole, an active firewall), so a probe failing the same way before and after can't pass blind; - the candidate's own output is consistent: its
planandplan --diffof one state (and its cold and warm runs) print one and the sameplan_hash; each plan names its machine directly under the header (over SSH,host:anduser:lines, thentarget:; on the local suite,user:andhome:lines, thentarget:); and no line of its output holds a control character; - in a scenario that reconverges, every check of the reconverge phase holds,
the apply fetched no remote content, and it left the candidate's
zap.lockaszap lockminted it. A failure here is tagged[reconverge], so it reads apart from a failure of the read-only phase.
Only an SSH scenario reconverges, and only one whose expected marks are all drift or absent (a held ? stops the preview at exit 1), with no
operator-side change and no remote content: the apply fetches nothing.
Replacing the bootstrap user
bootstrap-replace rehearses what an operator does once a host is up: replace
the stock account it was brought up with
(The bootstrap user) by a user of its own,
then remove the stock account. It runs on both distros, in the default matrix,
with the candidate alone and its own small configs, crates/xtask/fixtures/bootstrap/, kept apart from the kitchen sinks so the
base doesn't depend on them. From the base:
- Provision.
zapnextdoesn't exist yet. A config whose target connects aszapnext, withopsas itsbootstrap_user, declares that user withssh_keys: {copy_from: 'ops'},sudo: 'nopasswd', and/bin/sh. It is locked, previewed withapply --bootstrap, which must plan the user's creation and nothing else and nameopsas its user, applied withapply --bootstrap --wetrunbound to that preview, and previewed again, which must plan nothing. - Prove. Over its own login,
zapnextis who it says,sudo -n trueworks, and the grant is Zap's:/etc/sudoers.d/zapnextholds Zap's line, root's at440, andzapnextis in nosudo,wheel, oradmingroup. Itsauthorized_keysisops's, byte for byte. - Retire. As root through
zapnext's login (a session ofopscan't removeops),ops's sessions are ended and its processes killed, a bounded wait sees none left, anduserdel -r opsruns without-f, so a process that outlives the kill fails it. On Debian, cloud-init's/etc/sudoers.d/90-cloud-init-users, which grantsopsby name, must grant no one else, and is removed. - After. As
zapnext:opshas no account, no home, no sudoers line, and no group,visudo -cpasses, andzapnext's keys are unchanged. A login asopsis refused, and a login aszapnextright after succeeds, so a VM that went down can't read as the refusal. - Plan as
zapnext. A second config declares the same user with the same attributes, its keys as the list it holds (a module the harness writes fromzapnext'sauthorized_keys, never checked in). Itsplan, without--bootstrap, reads(no changes — converged)and nameszapnextas its user. The first config, itscopy_fromsource gone, plans the user again, and itsdetectgives the reason,ssh keys unreadable: a missing source reads as drift to apply, never as converged.
PASS ssh/bootstrap-replace 11.1s (revert 7.5s, provision 1.3s, prove 0.8s, retire 0.5s, after 1.0s)
the bootstrap user replaced in place: zapnext provisioned with --bootstrap, then ops retired over zapnext's own login
zapnext logs in, its sudo is zap's grant alone, and it holds ops's keys
ops retired: no account, home, sudoers line, or group; its login is refused
as zapnext: the retired config reads converged; the provision config's copy_from source reads unreadable, not convergedIt proves that sequence on these VMs, not every host's: a host whose sshd
admits only some users (AllowUsers), or whose stock account holds sudo some
other way, needs its own steps. It doesn't compare the two binaries or run
the read-only audit: it writes on purpose, and the audit reaches the VM as ops. Its own checks stand in: root's reading of the accounts and sudoers
after the retire, and the run fetching nothing. Every step after the retire
reaches the VM as zapnext. The revert that starts the next scenario, or the
run's last one, brings ops back.
Under --keep it stops after step 2, leaving ops in place: a VM left
without ops stops the next run before any revert, since the harness waits
for the VM as ops. A run killed after the retire leaves it that way; revert
the VM by hand:
virsh -c qemu:///system snapshot-revert <domain> differential-basePrerequisites
- Both VMs from testing.md, each with a
pristinesnapshot and your key inops'sauthorized_keys. ZAP_FGRO, the absolute path of thefgrobuild both binaries evaluate the fixtures with. Each run is given it as--fgro; each staged config, and the local bundle's, starts beside azap.lockpinning it, generated from that binary rather than checked in, since anfgrobuild names one machine's bytes; and the local bundle carries a copy to the Debian VM, whose kernel must holdfgro's sandbox (Landlock ABI 6 or later).- GNU
tarandgzip, andping. opensslonPATH, the first time, to make the test CA.- The libvirt
defaultnetwork active, and guests able to reach a TCP port on its gateway address (the firewall note applies to this too). - An oracle that evaluates through
fgro(it takes--fgro), and keeps its SSH control socket under itsZAP_HOME, as the working tree does. The harness checks both before it starts a VM: azapbuild that predatesfgrocan't read the staged locks. - No root: your user in the
libvirtgroup (Prerequisites).
Running it
cargo xtask differential # origin/main vs the working tree, both VMs
cargo xtask differential --distro alpine --scenario firewall-policy
cargo xtask differential --oracle-rev <rev> # the oracle built from another revision
cargo xtask differential --oracle ./zap-reference # a binary you built
cargo xtask differential --candidate ./zap-under-test --keep
cargo xtask differential --base-by candidate # the candidate builds the base
cargo xtask differential --compare semantic # compare decisions, not rendering
cargo xtask differential --help # every flag| Flag | Default | Meaning |
|---|---|---|
--oracle <path> | a build of --oracle-rev | the reference binary |
--oracle-rev <rev> | origin/main | the committed revision the oracle is built from; not with --oracle |
--candidate <path> | a release build of the working tree | the binary under test |
--distro <name> | both | alpine or debian; repeatable |
--scenario <name> | all | an exact scenario name; repeatable. An unknown name lists the valid ones. |
--compare <level> | exact | exact or semantic; see compare levels |
--base-by <side> | oracle | oracle or candidate: which binary builds the base |
--rebase | off | rebuild the base even when its hash matches |
--keep | off | skip the final revert and every reconverge phase, so you can inspect the VM in the last scenario's state (drifted, for a scenario that reconverges), or, after a failed base build, in the state the builder left it; bootstrap-replace stops before it retires ops |
Without --oracle, the oracle is ~/.zap/oracle/zap-<short-rev> for the commit
the revision names, as the local ref stands (nothing is fetched). It is reused
while that file is there; delete it to force a rebuild. Otherwise the harness
builds it the way you would by hand: the revision's tree from git archive in a
temporary directory under ~/.cache/zap-differential, beside a link to each
sibling checkout its Cargo.toml names by relative path, then cargo build --release -p zap there under the revision's own rust-toolchain.toml (CARGO_BUILD_JOBS applies), and the executable copied
into place. A sibling such as ../fuz is linked as it stands now,
uncommitted changes included, not as it was at the revision. The temporary
directory is removed afterwards; one an interrupted build left behind is removed
by the next build. The run prints which oracle it used:
oracle rev origin/main = 0123abc (reused /home/ops/.zap/oracle/zap-0123abc)The default is origin/main rather than the checked-out branch's upstream: the
oracle is what has landed, whatever branch the working tree is on.
| Exit status | Meaning |
|---|---|
| 0 | every scenario matched |
| 1 | any divergence or harness error |
| 2 | a usage error |
The run prints both binaries' blake3 and warns when they are byte-identical: such a run checks the harness and the targets, but proves nothing about a change.
Reading the output
Each scenario prints PASS, DIVERGED, or ERROR, its phase times, what its
mutation does, and the resources the oracle's detect saw leave ok; a
scenario that reconverges also times that phase and says when the candidate put
the state back:
PASS ssh/firewall-policy 25.1s (revert 5.0s, zap runs 6.0s, audit 0.3s, reconverge 12.4s)
default incoming policy flipped to allow
oracle detect moved: firewall(drift)
candidate reconverged it: the probe reads the base againA reconverge failure is an ERROR whose message starts [reconverge] and names
the check that failed; a probe that doesn't read the base again prints the
difference, - for the base and + for after the apply. A probe reads what the
declaration governs, no more: the pg_hba probe reads which of the database's
rules are present, not where, since Zap puts a missing rule ahead of the first host line rather than back where it stood.
A divergence prints, per command and stream, a unified diff with - for the
oracle and + for the candidate, tagged [cold], [warm], or [local]:
DIVERGED ssh/converged 14.2s (revert 6.5s, zap runs 7.7s, audit 0.3s)
oracle detect moved: nothing
[cold] detect:
stdout:
--- oracle
+++ candidate
…
ok sleeper-init file — converged
- ok sleeper service — converged
+ drift sleeper service — drifted (running=false, enabled=true)
ok postgresql package — converged
…Only what differs between the runs by construction is normalized: each run's ZAP_HOME path (<ZAP_HOME>), the binary's own path (<ZAP>), and its staged
fixture tree (<STAGE>). Everything else, exit codes and stderr included, is
compared verbatim, so every diff line is a behavior change: either the intended
one, which you record, or a regression.
An ERROR is not a verdict; the message says which check above failed, or that
the VM was unreachable, the base didn't converge, the two locks pinned different
things, or an intended-divergence entry went stale.
Compare levels
--compare exact, the default, compares byte for byte. --compare semantic is
for an oracle that renders a plan differently by design: it projects both sides
down to what each command decided (verdicts, banners, held resources, the set
and order of changes with their action, triggers, --diff attribute lines,
warnings, exit codes) and leaves out each plan's machine and references: block, the lines under a change, and the value of plan_hash. So a semantic PASS doesn't establish that the two binaries would run the same commands or
compute the same hash. The projection is compare::project_lines.
A change to what a plan's steps run, such as an extraction script, moves the plan_hash of every plan that runs it, and that hash also binds the plan's
references, among them the per-run port of the harness's loopback content server,
so no entry can record it. Against an oracle that predates such a change, an exact
run diverges on those lines by design: compare at the semantic level, where the
intended entries still apply. The exact level passes again once the oracle is a
build that carries the change.
Intended divergences
A candidate that changes behavior on purpose diverges by construction. Each such
change is an entry in crates/xtask/src/differential/intended.rs: the distro,
suite, scenario, cold or warm variant, command, the exact oracle and candidate
lines, the resource, and the reason. Matching is whole lines, exact bytes, and
only for the run the entry names; an accepted line prints as an INTENDED block
and doesn't fail the scenario:
PASS ssh/file-edge-cases 19.9s (revert 4.8s, zap runs 13.4s, audit 0.4s)
…
INTENDED [cold] detect Stdout source-copy: the unreadable-source hold names the path, not the old `cat` stderr
- ? source-copy file — undetermined: failed to render content: failed to read file …
+ ? source-copy file — undetermined: failed to read the content source: …An entry whose lines aren't among its run's changed lines is an ERROR, so
when the oracle is replaced by a build that already behaves the new way, its
entries fail and the table empties itself out. The table is reviewed like code.
api #
Browse the full api docs.