adm
Creating a machine from this document
This is the generic half of a two-part repository: the runbooks are the product, and each machine keeps its own configuration in a private fork. Nothing here describes a particular box, so it is the thing to start from rather than the thing to restore.
Create a machine from these runbooks.
Start with a stock Arch install you can reach over SSH, as root or a
user who can sudo, with working network and a usable mirror list.
1. Upgrade, and install the interpreter. adm is Python and cannot
install its own interpreter, so this comes before everything;
Arch’s base does not include it.
sudo pacman -Sy --needed --noconfirm archlinux-keyring
sudo pacman -Syu --needed --noconfirm python git
Both lines, in that order, and -Syu rather than -S. Arch has no
supported partial-upgrade path: against databases that have
already been synced, installing one package links it against
libraries newer than the ones on disk. On an image that has sat
for months that fails outright — today’s git wants libgcc and
libstdc++, which were split out of gcc-libs, and pacman refuses
the conflicting files rather than break the system. The keyring
goes first because packages may now be signed by keys the image
has never seen, and a full upgrade would abort on signatures
before it could fix itself. 000 states this rule and sequences
the same two steps; this is the same rule applied before there
is a repository to run it from.
Expect a large upgrade on an old image, possibly including the
kernel. If it replaces the kernel, reboot before continuing:
000’s reboot-needed check will say so, but the modules for a
running kernel are gone once its package is replaced.
2. Clone this repository to /opt/admin. It is the generic half:
every runbook, no machine’s configuration.
sudo git clone https://github.com/EnigmaCurry/adm /opt/admin
sudo chown -R "$(id -un)" /opt/admin
3. Name the box. This writes hosts/<box>/<box>.env with the values
adm must read before anything can decrypt, and tells you which
further keys the runbooks will require.
cd /opt/admin && ./bin/adm adopt <box>
4. Supply those keys, yourself, at a terminal:
./bin/adm env-fill
It refuses without one on purpose. A value passed on a command
line is in the shell history and in /proc, and in an agent’s
context if an agent is driving.
5. Run the runbooks in order. Each block’s check runs beside it,
and the first failure stops everything.
./bin/adm run 000 010 015 020 030 040
6. Decide what else this box is for. adm list shows the optional
runbooks and what each is for; adm enable adds one.
One step in there needs a person and cannot be automated away: 020
generates an ssh key on the box and its public half has to be
authorised on your forge before the repo can be pushed. The runbook
prints the URL and stops until it is done.
Read 000 before running it. Every block says why it exists, and the
ones that would be wrong for your machine are easier to find now
than after they have run.- adm — Arch Declarative Markdown
- Provisioning the machine
- Conventions for this repo
- Decisions
- 001 — Arch Linux, not NixOS
- 002 — btrfs snapshots, not ZFS
- 003 — Backups live at the hypervisor layer
- 004 — A shareable repo with secrets kept in it
- 005 — Two tiers of key: one operator, one per box
- 006 — The root filesystem stays top-level; no @, no true rollback
- 007 — The generic half becomes a public repo; machines are private forks
- 000 — Bootstrap
- Assumes
- Per-box values
- Which runbooks this box runs
- Adopting a box
- Requirement: vCPU capability
- Boot environment
- Order matters: keyring, then full upgrade, then everything else
- Reboot if the kernel was replaced
- Host identity
- Admin tooling
- Initialise the repo
- Git commit identity
- Put adm on PATH
- Record the resulting state
- Next
- 010 — The admin user
- 015 — Secrets in the repo, encrypted
- 020 — Off-box remote for this repo
- 030 — Disable cloud-init
- 040 — btrfs snapshots
- What this gives you, precisely
- What this does NOT give you — read this before relying on it
- Why this is numbered after 010
- Preconditions: root must be btrfs, and the admin user must exist
- Packages
- The root snapper config
- Retention policy
- Timers
- The pacman hooks
- adm brackets every invocation
- End-to-end proof
- Using it
- Next, if this proves useful
- 045 — User homes are subvolumes of their own
- 050 — Boot into snapshots (grub-btrfs)
- What this is for, and what it is not
- Read this before relying on it: selection needs the console
- Preconditions
- The package
- Make a booted snapshot usable
- A menu you can actually catch
- Generate the menu
- The regeneration daemon
- Assert the snapshot entries would actually boot a snapshot
- Guard against the grub-reboot trap
- Using it
- 060 — Development packages
- 065 — System utilities
- 070 — Agent harness
- 080 — The rendered document
adm — Arch Declarative Markdown
There are two questions you should be able to answer about any machine you run:
how do I rebuild it, and why is it configured this way? On an Arch Linux box
that has been alive for a year, both answers are usually "I don't remember."
State accumulates imperatively, one pacman -S at a time, and the reasoning
behind it was never written down anywhere.
The answer, always, is to put something declarative over a system that is not one. The two established ways differ in how far they go.
Ansible wraps. The box stays imperative underneath; a playbook describes the state you want and converges the machine toward it, run after run. That answers the first question and not the second, because a playbook records what to converge to and never why you chose it.
NixOS replaces. There is no imperative path left to drift down — the build sandbox makes the configuration the only channel through which the system can change, so one file answers both questions. That enforcement is exactly what adm does not have, and if you can use Nix it is worth more than anything here. It was considered and rejected for this box; DECISIONS.md #001 records the reasoning and what would reverse it.
adm wraps. Arch stays imperative underneath and nothing stops you reaching past it. The bet is that the wrapper can be prose: a box whose setup is contained entirely within a set of runbooks is effectively declarative anyway — the runbooks stop being a record of what happened and become the definition of what the box is. Replay them and the machine comes back.
A runbook is ordinary Markdown — this is a literate administration repo,
and runbooks/ is all of it. The prose explains why; the commands
sit beneath it in fenced blocks, and those are the ones that actually run:
`btop` replaces top or htop, with no runtime dependencies beyond libuv and
ncurses. The agent harness needs to watch system resources during long runs,
and `btop` makes that obvious rather than parsing text output.
```bash id=btop
sudo pacman -S --needed --noconfirm btop
```
```bash id=btop verify
pacman -Qi btop >/dev/null || { echo "btop not installed"; exit 1; }
command -v btop >/dev/null || { echo "btop not on PATH"; exit 1; }
```
That is 065 — the run block verbatim,
the verify block with one of its three assertions dropped for length.
The explanation and the commands are one artifact, so they cannot drift apart.
And every change carries a verify block asserting the property it was
supposed to establish. That is the part that matters: the documentation can
fail. adm verify runs every assertion in the repo, so a document that has
quietly become fiction announces itself instead of waiting to be discovered.
That falsifiability is also what makes the box reasonable to hand to an agent.
You describe an outcome; the agent reads the conventions, writes the runbook,
shows you its plan, and stops. One approval, then it runs. It is not permitted
to configure the machine directly — the only channel it has is the one that
documents itself, so the write-up is a side effect of the work rather than a
chore after it. See 070 for the harness and
the approval gate.
The honest limit: containment is a convention here, not an invariant. Nothing
errors when you break it — the repo just stops being a rebuild procedure and
becomes a diary, which is why it has to be held deliberately; see
AGENTS.md. adm facts is the counterweight, snapshotting packages,
enabled units, listening sockets and /etc so that a git diff surfaces drift
nobody wrote down.
The top-level claim is falsifiable too: replay onto a bare VM, finish with a
clean adm verify. That test is owed, not passed — DECISIONS #001 records
it as this project's acceptance test. Until it passes, this is a premise with
good evidence behind it.
The project and the box
This repo is not scaffolding around some other purpose; it is the purpose. The
runbooks and adm are the system under development, and
rea-dev is the first box they describe — both the workbench and
the first subject. The acceptance test above is owed by the thing being built,
to itself.
The box name is a nod to REA —
"Reverse Engineer Anything" — which is intended to run there later inside an
agent harness. That is not set up yet, and no runbook describes it. The project
took its own name afterwards, which is why the box and the repo no longer
match — rea-dev is a machine, adm is what describes it.
The box
| host | rea-dev |
| distro | Arch Linux (rolling) |
| origin | stock Arch image, built 2026-01-15 by arch-boxes CI |
| admin repo | /opt/admin (this directory) |
| adopted | 2026-10-18 — see AS-FOUND.md for provenance, hardware and loose ends |
Usage
adm list # every runbook and the blocks it will run
adm run 000-bootstrap.md # run that runbook's blocks, verifying as it goes
adm verify # run every verify block in every runbook
adm replay # run every runbook in order — i.e. rebuild
adm facts # snapshot system state into facts/, commit if changed
adm log # the run journal
adm run and adm replay stop at the first failure. adm verify runs
everything and reports all failures, so it is safe to use as a health check.
Add -n / --dry-run to see what would execute without running it. To work
around a block that is failing for a reason you already understand — a pending
reboot, say — use adm run <runbook> --only <block-id> (repeatable) to run a
subset, or -k / --keep-going to continue past failures.
Which flags each of those three takes, exactly:
adm run -n --only -k --no-snapshot
adm replay -n -k --no-snapshot
adm verify -n
That is a fence rather than a sentence because 000 checks it against the
parser. The prose version said -k could be added "to any of them" while
adm replay -k was an error, and nothing noticed: a claim about a program,
written beside the program, in a form the program cannot read.
Layout
The repo splits in two: the generic half is the product, and each box
gets a namespace directory for its own configuration and state. One repo
can describe several machines; adm picks the box directory automatically when
there is only one, and by $ADM_BOX or hostname when there are more.
| path | what |
|---|---|
runbooks/NNN-topic.md |
generic — the runbooks, applied in filename order |
bin/adm |
generic — the runner, stdlib Python 3, no dependencies |
AGENTS.md |
generic — conventions, for any agent and for humans |
CLAUDE.md |
generic — a symlink to AGENTS.md, for harnesses that look for that name |
DECISIONS.md |
generic — why things are the way they are, and what would reopen each choice |
.sops.yaml |
generic — encryption rules for the per-box env files |
hooks/pre-commit |
generic — refuses to commit an unencrypted env file or any private key |
hosts/.gitignore |
generic — ignores every box, so no machine state is published by default. A fork appends !/<box>/ to adopt its own |
hosts/<box>/<box>.env |
per-box values, secrets encrypted. The only file that must differ between machines |
hosts/<box>/AS-FOUND.md |
per-box — its state at adoption. Written once, never maintained |
hosts/<box>/facts/ |
per-box — generated snapshots of system state, committed on change |
hosts/<box>/log/journal.md |
per-box — append-only record of every block ever run |
hosts/<box>/log/runs/ |
per-box — full output of each run (gitignored) |
Getting started
Two different jobs, with different prerequisites. Read the one you are doing.
Both share one non-obvious step. adm finds a box by looking for
<dir>/<dir>.env, picking it automatically when there is only one and
otherwise matching $ADM_BOX or the hostname. A freshly provisioned box still
carries the image's hostname — arch, typically — so once this repo
describes more than one machine, selection by hostname fails until
000 sets the real name. Until then, pass it:
ADM_BOX=<box> adm replay
A — a new machine from barebones Arch
For standing up a box this repo does not yet describe.
Prerequisites: an Arch VM you can reach over SSH as root or as a
sudo-capable user. Prefer a btrfs root: 040
requires it and refuses to run otherwise, and Arch's own arch-boxes images
are btrfs already. If the box was provisioned by cloud-init, let it finish
before touching anything — it is what installs root's authorized_keys, which
010 copies to the admin user, and 030 explains why it must not be disabled
before then.
-
Get the repo onto the box at
/opt/admin. On a brand new machine usersyncfrom a workstation that already has a clone — cloning on the box itself needs a deploy key that does not exist yet:rsync -a --exclude '.git' ./ root@<newbox>:/opt/admin/ -
Adopt the box.
adm adopt <box> adm adopt <box> --from workstation # and opt into a purposeA profile is an env preset, not a different set of runbooks: it carries the opt-in list for a purpose and records
ADOPTED_FROM.templates/holds them as<name>.preset, and a preset may contain only keys the encryption policy exempts — so it can turn070on but cannot carry its credentials.That creates
hosts/<box>/, writeshosts/<box>/<box>.envwith the keys that have to be readable in the clear, appends the anchored negation tohosts/.gitignore, and stages both. It then prints the keys the later runbooks will require, and which runbook requires each.It does not restate that key list anywhere: it reads the runbooks for the assertions they already fail on. This paragraph used to carry the list by hand and was wrong for several hours after
010began requiring a third key, which is why the command exists and why000'sadopt-roundtripnow fails if the two ever disagree again.The keys it writes are readable in the clear by necessity:
010creates the admin account before015generates this box's age key, so at that moment nothing on the machine could decrypt a value..sops.yamlexempts them for that reason.what makes the box yours; a
git add -fis not a substitute. -
Bootstrap and create the admin user, as root or with sudo:
ADM_BOX=<box> python3 /opt/admin/bin/adm run 000 010000brings the system current, sets the hostname fromBOX_NAMEand putsadmonPATH;010creates the admin account, grants passwordless sudo and hands it ownership of the repo. After this, connect asssh admin@<box>and stop using root. -
Reboot if
000says the kernel changed, then carry on. It tells you explicitly rather than rebooting for you. -
Set up secrets —
015generates this box's age identity, adds it to.sops.yaml, and arms the git hooks:adm run 015The box key it generates is disposable — it only ever reads this box's own env file, and a rebuild can replace it. What must already exist is an operator recipient listed in
operator.pub, whose private half lives on your workstation at~/.config/sops/age/keys.txtand in your password manager. That is the one key whose loss is unrecoverable; see015for the two tiers. -
Add any secret values this box needs to its env file, then encrypt:
sops hosts/<box>/<box>.env # edit decrypted, saves encrypted adm run 015 --only encrypt-env -
Give it a remote —
020generates a deploy key and prints it. Add it to the forge with write access, thenadm run 020finishes and pushes. -
Finish the rest, then check:
adm replay && adm verify && adm facts
A second box is also the first real test of whether the generic/per-box split
holds. Expect to find things that are still accidentally specific to
rea-dev; that is useful information, and fixing it belongs in the runbooks
rather than in the new box's env file.
B — reproducing a machine this repo already documents
For rebuilding a box after loss, or recreating it to prove the documentation is true. Doing this successfully is this project's acceptance test — see DECISIONS.md #001, where it is recorded as owed.
Create the replacement VM to match. Nothing inside the guest can check
these until it is too late to change them cheaply, and hosts/<box>/facts/ in this
repo records what the original had:
| requirement | why | where recorded |
|---|---|---|
CPU type host (or ≥ x86-64-v3) |
070's agent is a Bun binary needing AVX2. Proxmox defaults to kvm64, which is v1. 000 fails early rather than late, but fixing it needs a VM stop/start — a guest reboot will not apply a CPU model change |
facts/system.txt |
| btrfs root | 040 requires it and refuses otherwise. Arch's own arch-boxes images are btrfs |
facts/storage.txt |
| GRUB, on BIOS or UEFI | Firmware is not the constraint — 050 only calls grub-mkconfig, never grub-install, and Arch puts grub.cfg in the same place either way. What it cannot drive is systemd-boot, which has no snapshot-boot equivalent. Also: /boot must sit on the root btrfs, so a snapshot carries its own kernel — a UEFI install that mounts the ESP at /boot breaks that |
facts/system.txt |
cloud-init provisioning root's authorized_keys |
010 copies them to the admin user. Without them you cannot log in at all, and no runbook can fix a box you cannot reach |
AS-FOUND.md |
That last one is the real hazard: it is the only failure here that locks you out rather than failing loudly.
Prerequisites — get these in hand first. Both are discoverable halfway through a rebuild, which is the worst possible moment:
- The operator age key, on the workstation at
~/.config/sops/age/keys.txt. A rebuilt box generates a new identity which is not yet a recipient of its own encrypted env file, so it cannot decrypt its own configuration until someone re-keys the file — and only an operator can, because only an operator can read it. This is the prerequisite that makes a rebuild possible at all; without it the configuration is permanently unreadable, which is what encryption means. The old box key is not needed. That is the point of the two-tier design — box keys are disposable, see015. - Forge access for a deploy key. The rebuilt box generates a new one, so be ready to paste its public key into the forge with write access.
-
Do not delete the original yet. Shut it down and build the replacement alongside it. A stopped VM costs nothing, and it turns an irreversible test into a reversible one — if the rebuild stalls, start the original and you have lost an afternoon rather than a machine. Delete it only once the replacement passes step 7.
-
Provision barebones Arch to the requirements above, matching the original's filesystem —
hosts/<box>/facts/storage.txtrecords what it was. -
Get the repo onto the box at
/opt/admin, byrsyncfrom a workstation clone. Cloning on the box needs a deploy key that does not exist yet, sorsyncfirst and let020sort out the remote later. -
Bootstrap and create the admin user. Stop here — do not replay further yet, because the next step needs the admin user's home to exist:
ADM_BOX=<box> python3 /opt/admin/bin/adm run 000 010 -
(Optional shortcut.) If you kept the old box key, restore it before
015runs and the rebuild proceeds without the operator step below:install -d -m 0700 ~/.config/sops/age install -m 0600 /path/to/restored/keys.txt ~/.config/sops/age/keys.txt015'sage-keyblock only generates a key when none exists, so a restored one is left alone. This is a convenience, not a requirement. -
Replay, and expect it to stop twice. Both stops are the runbooks reporting something untrue, not defects:
adm replay- At
015'ssync-env-recipients, because the rebuilt box's new key is not yet a recipient of its own env file. The block prints the fix: from the operator workstation, in a clone of this repo,git pullthensops updatekeys -y hosts/<box>/<box>.env, commit and push. Thengit pullon the box and re-run. Do not copy the operator key onto the box. - At
020'sforge-authorized, until the new deploy key is authorised on the forge with write access.
- At
-
Reboot when
000reports the kernel changed, thenadm replayagain. Every block is idempotent, so this is safe and expected. -
The actual test:
adm verifyIt must come back completely clean. If it does not, the documentation is wrong, and that is a bug in this repo — fix the runbook, not the box.
-
Compare against the recorded state.
adm factswrites a fresh snapshot over the committed one, sogit diff hosts/<box>/facts/is a direct comparison between the rebuilt machine and the one this repo described. Expect noise only in genuinely variable things; anything structural is a reproduction gap worth a runbook change.
Honest limitations
- Runbooks only know what they were told. A change made by hand at the
shell is invisible to them.
adm factsis the counterweight: it snapshots package lists, enabled units, listening sockets, users,/etcfile manifests and pacman-modified-file checks, so agit diffinhosts/<box>/facts/reveals drift even when nobody documented it. Run it after any session. - Idempotence is the author's job. Nothing enforces it. Use
pacman -S --needed,install -m,systemctl enable --now,ln -sfn,mkdir -p, and guard edits so re-running is a no-op. - Encrypted, but not a secret manager. Secrets do live in here, encrypted
with SOPS and age — see
015. Values are ciphertext; keys stay deliberately readable, so the shape of the configuration is public even when its contents are not. Nothing rotates, expires or audits access. Credentials still do not belong in runbook prose — reference where they come from instead. Andhooks/pre-commitis the only thing between a plaintext value and the remote: it scans staged content, socommit --no-verifywalks straight past it, and anything once committed in the clear stays in history. - Backup is not this repo's job. It happens at the hypervisor layer, which
nothing inside the guest can verify — so no verify block covers it, and none
should pretend to. The btrfs snapshots set up by
040are convenience only. See DECISIONS.md #003.
Prior art and lineage
Read this before assuming the conventions here are ones you already know. The ideas are established; the syntax is local to this repo.
What is standard:
- "Runbook" is long-standing operations vocabulary — data-centre run books, ITIL operational procedures, SRE playbooks, incident runbooks. "Runbook automation" is a product category.
- Literate programming is Knuth, 1984 (
noweb,cweb). - Literate DevOps — applying it to systems administration — was popularised by Howard Abrams around 2014–16 using Emacs org-babel to execute shell blocks against hosts. That is the closest named ancestor of this repo. Note what that approach does differently: it runs from the workstation over TRAMP, so nothing is installed on the target. We traded that away so the box can replay its own runbooks unaided.
- Documentation that fails is Python's doctest, Rust doc tests, Go
examples. The
verifyblock is doctest-shaped. DECISIONS.mdfollows MADR, the Markdown form of Nygard's 2011 Architecture Decision Records.- Executable Markdown is a real tool category, with several maintained implementations: Runme (CLI and VS Code, the most mature), runmd, mdrb, runbookmd.
What is not standard — and this is the important part — is the fence
syntax. CommonMark specifies nothing about execution attributes on code
fences, so every tool in that list invented its own convention; see the
syntax comparison across projects.
So id=, verify and skip are ours. No other tool reads them, and
nobody arrives already knowing them. The full rule set is five lines in
AGENTS.md.
Why a bespoke runner instead of Runme
adm needs only python3, which ships in Arch's base. The stock image that
became this box had no git, no make, no jq — on a bare rebuild target
adm replay runs with zero bootstrapping. Runme is a Go binary you would
have to get onto the box before you could follow the runbook that installs
things, which is a chicken-and-egg problem on exactly the machine where it
matters most. Runme also has no verify-block equivalent, which is the
feature doing the real work here.
One accidental virtue: the attributes degrade gracefully. Renderers key
syntax highlighting off the first info-string token, so ```bash id=foo verify still renders as a normal highlighted bash block on GitHub and
elsewhere. These files stay readable as plain documentation with no tooling
present at all.
Provisioning the machine
Everything else in this repository runs on a box. This runs on the
hypervisor, before the box exists, and adm never touches it — there is no
runbook here because there is nothing for adm to verify yet.
Written for Proxmox, because that is what this was built on. The requirements below are what the runbooks check for; any hypervisor or bare metal that meets them will do.
What the runbooks require of the machine
Each of these is enforced by a check, named so you can see what refuses and where:
| requirement | enforced by | if missing |
|---|---|---|
| btrfs root | 040#root-is-btrfs |
snapshots are unavailable; the runbook refuses rather than half-working |
GRUB, with /boot on the root filesystem |
050#grub-btrfs-preconditions |
no bootable snapshot entries |
| x86-64-v3 CPU (AVX2) | 000#cpu-capability |
the agent harness in 070 spins at 100% producing nothing |
python installed |
000's Assumes |
adm cannot run at all, including the block that installs things |
SSH, as root or a user who can sudo |
— | nothing can be driven remotely |
The Arch cloud image satisfies the first two as it ships. It is not an ext4 image that needs converting: the layout it produces is
sda1 1M BIOS boot partition, for GRUB on GPT
sda2 300M vfat ESP, automounted at /efi
sda3 ...G btrfs root, with compress-force=zstd
which is the layout 040 and 050 were written against — and the layout
rea-dev has, because it came from this image. DECISIONS.md #002 records
discovering that: a migration was planned on the belief that root was ext4,
and root was already btrfs.
The third requirement is the one you must add by hand. See below.
Create the template
The script is from the Proxmox chapter of book.rymcg.tech and builds templates for several distributions; only Arch is relevant here.
On the Proxmox host:
wget https://raw.githubusercontent.com/EnigmaCurry/blog.rymcg.tech/master/src/proxmox/proxmox_kvm.sh
chmod a+x proxmox_kvm.sh
DISTRO=arch TEMPLATE_ID=9000 STORAGE_TYPE=local STORAGE=local-lvm ./proxmox_kvm.sh template
It downloads Arch-Linux-x86_64-cloudimg.qcow2 from mirror.pkgbuild.com,
imports it, sets rootflags=compress-force=zstd and a serial console in
GRUB, installs qemu-guest-agent, re-initialises the pacman keyring,
upgrades, and converts the VM to a template.
Read the variables at the top of the script before running it:
STORAGE_TYPE and STORAGE for a different backend, SSH_KEYS for the
public key cloud-init will authorise, FILESYSTEM_SIZE, NUM_CORES,
MEMORY. On NFS storage chattr +i fails during template creation and that
can be ignored.
Set the CPU type, which the script does not
The script configures fourteen qm flags and --cpu is not among them, so
the template inherits Proxmox's default — kvm64, which is roughly
x86-64-v1: no AVX2, no AVX, not even SSE4.2 or AES-NI.
qm set 9000 --cpu host
Set it on the template so clones inherit it. Without it,
000#cpu-capability stops the run before anything is installed, which is
the check working — 070's agent harness is a Bun binary that needs AVX2,
and on a CPU without it the failure is not an error but a process spinning
at 100% for ever. This box shipped as kvm64 and 070 was blocked until
the VM's CPU type was changed.
host passes the physical CPU's features through, which also means a
migration to a host with an older CPU can take features away. On a cluster,
set a named model that every node supports instead.
A template ages
The script upgrades the image while building the template, so a template is current on the day it is made and not afterwards. A clone of a template built months ago is a stale Arch install with current package databases, and that combination is the one Arch handles worst: installing a single package against synced databases is a partial upgrade, which is unsupported and fails.
It is not hypothetical. The first machine built from these instructions was
cloned from a nine-month-old template, and the first command in the creation
directive — installing git — aborted on conflicting files, because today's
git depends on libgcc and libstdc++, which have since been split out of
gcc-libs, and the old gcc-libs still owned those files. Nothing was
damaged; pacman refused, which is what it is for.
So either rebuild the template before cloning from it:
qm destroy 9000
DISTRO=arch TEMPLATE_ID=9000 STORAGE_TYPE=local STORAGE=local-lvm ./proxmox_kvm.sh template
qm set 9000 --cpu host
or expect the first step on the clone to be a full system upgrade, which is
what the creation directive now does: archlinux-keyring first, then
pacman -Syu with the packages, in one transaction. On an image that has sat
for a long time that can be a hundred or more packages including the kernel,
and a reboot before continuing.
Rebuilding is cheaper and gives a clone that boots ready. Upgrading is the only option for a template you cannot rebuild.
Clone a machine from it
TEMPLATE_ID=9000 VM_ID=100 VM_HOSTNAME=my-box ./proxmox_kvm.sh clone
qm start 100
VM_ID=100 ./proxmox_kvm.sh get_ip
Then continue in 000-bootstrap.md, or follow the
creation directive at the top of the published document, which is the same
sequence written for someone who has only that page.
Cloning for the cold-boot test
The point of a disposable clone is a box that has never been bootstrapped. Several checks in this repository can only fail there: a verify block that asserts state a later block creates passes forever on a box that was set up once, because the state is there from last time. Seven defects of that shape were found by doing this by hand, and nothing in the repo can catch the next one.
So a clone made for testing should be destroyed afterwards rather than kept and re-run — a second run on the same box is a poor test of the first.
Conventions for this repo
Read README.md first for what this repo is and how adm works.
This file is the contract for adding to it. It applies to Claude and to humans equally.
The rule
Nothing changes on this box except through a runbook. If you are about to
run a command that alters the system, it goes in a runbook first, and you run
it with adm run. Reaching for a bare shell to "just quickly" install or edit
something is how the documentation becomes fiction.
Read-only investigation at the shell is fine and encouraged. Mutation is not.
This is not bureaucracy — it is the single load-bearing assumption of the whole repo. Arch is an imperative system, but setup contained entirely within the runbooks is effectively declarative: the runbooks become the definition of the box, and the box becomes a replayable consequence of them. That is the same property NixOS provides, obtained by discipline instead of by a build sandbox, and it is why this repo stays plain enough to read as prose. See DECISIONS.md #001.
The difference is that Nix enforces the single channel and this repo only
assumes it. Break the assumption and nothing errors — the repo quietly stops
being a rebuild procedure and becomes a diary. adm facts is the only
detector, which is why it runs at the end of every session.
Writing a runbook
Name it runbooks/NNN-topic.md, where NNN leaves room to insert later:
step by 10 within a theme, by 100 between themes. Filename order is
execution order, so a runbook may depend on anything numbered below it and
must not depend on anything above.
Structure:
# NNN — Title- A short paragraph on why this exists — the decision, the constraint, the thing that will be non-obvious in six months. This is the part that no other tool (etckeeper, Ansible) preserves. Write it for a reader who has forgotten everything.
- Then, per change: prose explaining it, the
bashblock that does it, and averifyblock that proves it.
Block syntax
This syntax is local to this repo — not a standard, and not read by any other tool. Do not assume familiarity from elsewhere; see README.md for what here is established practice and what is invented.
A bash block is executed if and only if it has an id= attribute:
```bash id=install-nginx
pacman -S --needed --noconfirm nginx
```
```bash id=install-nginx verify
pacman -Qi nginx >/dev/null
systemctl is-enabled nginx
```
id=<slug>— makes the block executable; unique within its runbook.verify— assertion block. Runs immediately after the block with the sameid, and again on everyadm verify. Non-zero exit = failure.skip— parsed, logged, not executed. For blocks retired but kept for the historical record.
One block is not bash at all. Every runbook declares, directly under its purpose line, whether every box runs it:
```adm
required
```
required runs everywhere. optional runs only where the host env lists its
number in OPTIONAL_RUNBOOKS. A runbook declaring neither is unclassified
and adm will not run or verify it — not "required by default", because a new
runbook silently running everywhere and one silently running nowhere are both
wrong, and only one of them is visible. 000's runbook-classification
fails on an unclassified runbook, and on an opt-in naming something that does
not exist or is not optional.
An optional runbook nobody enabled is silent during a run — no warning, no
skip notice — and still listed by adm list, with how to enable it. Silence
is for things deliberately not in use; a misspelled opt-in is a different
matter and fails loudly.
A required runbook must never depend on an optional one. The optional one may not have run, and the bug would appear only on boxes that did not opt in. Nothing enforces this: required runbooks already link to optional ones nine times, every one a cross reference rather than a dependency, so a scan would report nine false positives and teach everyone to ignore it. Telling "refers to" from "relies on" needs a person.
A bash block with no id= is illustrative and never runs — use that for
"here is what the broken output looked like". Blocks in any other language
(ini, toml, text, …) are never executed, so config file contents can be
shown inline for readability and then written by a bash block.
Every block runs under bash with set -euo pipefail, with the repo root as
cwd, and with $ADM_ROOT, $ADM_RUNBOOK, $ADM_ID in the environment. Use
$ADM_ROOT rather than hardcoding /opt/admin.
Every key in <box>.env is also exported — $BOX_NAME,
$ADMIN_USER, $GIT_REMOTE. Runbooks must be generic: anything true of
one machine rather than of the procedure belongs in <box>.env, and anything
that is history rather than procedure belongs in
AS-FOUND.md. A runbook that accumulates a specific box's
story stops being runnable on the next box, which is most of its value gone.
Two parts of that are checked, by 080's generic-runbooks. A box's name may
not appear in an executable block — that is code, and a name in it makes
the procedure wrong on the next machine; use $BOX_NAME, which adm
substitutes before the block runs. Nor in a link target: a link to
hosts/<box>/AS-FOUND.md resolves on one machine and is dead in every fork,
so per-box files are referred to by name without a link.
Prose and illustrative blocks may name a box, and should where a concrete
example reads better. Substitution reaches executable blocks only, so a
placeholder written into an example reaches the published document as a
placeholder: sops <box>/<box>.env is something nobody can paste. The generic
source is what the export's templates carry; the rendered page is allowed to
be about the machine that produced it.
Run adm list after editing to confirm the runner sees what you intended.
Idempotence is mandatory
adm replay must be safe to run on an already-configured box, because that is
what makes this repo a rebuild procedure rather than a diary. Concretely:
pacman -S --needed --noconfirm, never bare-Smkdir -p,ln -sfn,install -D -m 0644systemctl enable --now, which is already idempotentuseraddguarded:id -u svc >/dev/null 2>&1 || useradd -r svc- For config files, write the whole file from a heredoc rather than
appending or
sed-patching in place. Append-based blocks duplicate their own output on the second run. - Never
pacman -Sywithout-u. It is how this box got into a partial-upgrade state in the first place.
Verify blocks should assert, not narrate
Check the property you actually care about, and exit non-zero when it does not
hold. systemctl is-active foo is a good verify; systemctl status foo is
not, because it succeeds regardless. Prefer checking observable behaviour
(a port answers, a file has the expected mode) over checking that you ran the
command (a package is installed).
Verify blocks must be read-only and fast. They run on every adm verify,
including when someone is debugging a broken box.
A verify block must be able to see what it asserts. Runbooks run as
admin, so an unprivileged check against a root-owned path can fail for the
wrong reason — or, far worse, succeed for the wrong reason. test ! -e /root/.ssh/config passes whenever /root is unreadable, reporting absence
when it has merely been denied a look. Absence assertions in particular must
acquire privilege (sudo test -e ...) and should confirm the containing
directory is readable first, so "not there" is distinguishable from "not
visible". This exact bug shipped here and survived until a replay as admin
caught it; running the runbooks as root hides the entire class.
A verify block must fail only for the reason it names. Distinguish "the
property does not hold" from "I could not observe it" — these are different
findings, and only one of them is a bug in the box. Empty output is not a
negative result; that is the rule above, applied past the filesystem. A probe
that shells out to something which starts a server, waits on a timer or
touches the network will sometimes come back with nothing, and that is a
retry, not a verdict. Nor should you discard stderr from the command whose
failure you are diagnosing: 2>/dev/null is how a failing block produces a
log holding one line of conclusion and no cause. This shipped here as well —
agent-gate reported the agent's permission gate missing, twice, because
opencode debug agents returns nothing while another opencode is mid-start,
which during adm verify is the normal path rather than an edge case. The
determinism demanded of fact collectors below applies here too, and for a
sharper reason: a noisy collector produces a diff you learn to ignore, while a
flaky verify raises a false alarm about the one property you built it to
watch.
Let a service settle before asserting it runs. systemctl is-active
immediately after systemctl enable --now can return active for a unit that
has already exited, because systemd has not reaped it yet. This shipped here:
grub-btrfsd died after 11 ms from a missing optional dependency and the run
block reported success. Pause, then re-check, and assert the main PID is
actually alive — not merely that the unit was once started.
Fact collectors must be deterministic
hosts/<box>/facts/ earns its keep only if a diff means something changed. A collector
whose output varies while the system does not produces fake diffs, and a few
reboots' worth of those teaches you to skim past the one file you should be
reading — which is strictly worse than not collecting it.
So: run a collector twice in a row and require byte-identical output. Known offenders, all of which bit this repo already:
- PIDs and FDs —
ss -tulpnrenumbers every boot. Stripped withsed, keeping the process name, because a new or differently-owned listener is real drift and must still appear. - Timings —
systemd-analyzereported 38.7s and 5.9s for two identical boots. Removed outright. - Idle-timer automounts —
/efiis an automount withTimeoutIdleSec=120, so its presence indfdepends on snapshot timing. Excluded from the live mount view, with the target list recorded separately since that list is stable configuration. - Unsorted output — sort anything whose order is not guaranteed.
When you must drop something transient, record the stable part of it rather than dropping the subject entirely. And prefer normalising to excluding: an omitted collector hides real drift just as effectively as a noisy one buries it.
Finishing a session
adm verify # everything still true?
adm facts # snapshot state; commits only if something changed
git -C /opt/admin status
If adm facts shows drift you did not make through a runbook, do not just
commit it — find out what changed, then either write the runbook that explains
it or revert it. Committing unexplained drift silently turns this repo back
into an audit log.
Secrets
Do not put credentials in runbooks. Document where a secret comes from and how
to install it ("the deploy key lives in <password manager>; install to /root/.ssh/id_ed25519 mode 0600"), and let the verify block check the file's
mode and fingerprint rather than its contents.
Decisions
Append-only. One entry per decision that shaped this box, newest last.
A runbook records how something was done. This file records why that approach — the forks in the road, what was rejected, and the conditions under which a decision should be reopened.
Format. Entries use the field names of an Architecture Decision Record (Nygard, 2011) in its Markdown form, MADR: Status, Context and problem statement, Decision drivers, Considered options, Decision outcome, Consequences, Confirmation. Standard names are used so the structure is recognisable rather than invented here — see README.md.
One local addition: Reopen this if. MADR's Status field can be set to
superseded after the fact, but nothing in the template says in advance what
should trigger that. Writing the trigger down while the reasoning is fresh
is the difference between a decision record and a diary entry.
001 — Arch Linux, not NixOS
- Status: accepted
- Date: 2026-10-18
- Deciders: ryan
Context and problem statement
This box is a stock Arch image (KVM guest, legacy BIOS boot, btrfs root)
being adopted as a service host. The workstation administering it runs NixOS
with a declarative disko disk layout, so NixOS was the obvious default and
was seriously considered, including the in-place conversion paths.
The question: should the box be converted to NixOS to get declarative, reproducible configuration, or stay on Arch and obtain that property another way?
Decision drivers
- Reproducibility: a documented path to recreating the box from scratch.
- Administration assumes no hypervisor console access. Console in fact exists, but the box is deliberately managed as though it does not, so no procedure may depend on it.
- Legibility: the configuration should be readable by anyone who can read a shell command.
- Incident ergonomics on a long-lived service host.
- Scale: one host, one operator, 195 packages, 12 of them explicit.
Considered options
- Stay on Arch, contain all setup in literate runbooks.
nixos-anywhere+disko— kexec into an installer, wipe, declaratively install.nixos-infect— install Nix on the running system, swap the bootloader in place.- Side-by-side NixOS install, Arch kept bootable as fallback.
- Provision a fresh NixOS VM and migrate the service onto it.
Decision outcome
Option 1 — stay on Arch, contain all setup in the runbooks.
The governing insight, in the operator's words:
NixOS is declarative, but it fights our runbook plainness. Arch is imperative, but if we contain setup to the runbook, it becomes effectively declarative, yet plain.
That is the whole design. Declarativeness is not a property of the OS — it is a property of the discipline of a single channel for change. Nix enforces that channel with a type system and a build sandbox, and charges real complexity for it. A runbook enforces it by convention and charges almost nothing. Convention is weaker, but it is legible.
Supporting reasons, roughly in order of weight:
- The conversion was the risk, not the OS. An in-place conversion is a
bootloader, initrd and root-filesystem transplant performed
simultaneously — and this box boots legacy BIOS, so there is one MBR stage
and no
efibootmgrescape hatch. Steady-state comparison favours NixOS; the comparison that mattered was steady-state NixOS minus an unrecoverable transplant. - Scale mismatch. Nix's value scales with configuration complexity and with the number of hosts and people needing identical environments. Here: one host, one operator, a system whose entire state fits on a page.
- Nix's guarantee doesn't cover what breaks service hosts. It makes the system closure reproducible. It does not manage service data, disk layout (without re-running disko destructively), or secrets. Outages come from the database, the certs and the full disk, not from package drift.
- Rollback is already better here. Booting an older NixOS generation reverts the closure, not on-disk state — old code against a migrated database is arguably worse than no rollback. This box already runs btrfs, where snapshots can revert system and data together.
- Incident ergonomics.
pacman -S straceduring an outage, standard FHS paths, and third-party prebuilt binaries that run withoutpatchelfor an FHS wrapper.
Pros and cons of the other options
| option | assessment |
|---|---|
2. nixos-anywhere + disko |
Technically the cleanest conversion, and the one to use with console access. Rejected here because kexec is a point of no return whose worst case is an ISO reinstall — precisely what the no-console driver forbids. |
3. nixos-infect |
Targets Debian/Ubuntu cloud images; Arch untested. Inherits the existing imperative partitioning, giving up the declarative disk layout that is half the reason to switch. Strictly worse than option 2 given an empty 250 G disk. |
| 4. Side-by-side install | Safest conversion — worst case is a power reset back into working Arch. Still a BIOS GRUB chainload transplant, and it leaves the disk layout non-declarative. |
| 5. Fresh NixOS VM, migrate | Not rejected on merit — deferred. Strictly better than converting this box: clean disko layout, UEFI if wanted, one mental model shared with the administering workstation, and a tested host before anything depends on it. This is the path to take if this decision is reopened. |
Consequences
Good
- No risky operation is required; the box is already running and current.
- The configuration stays readable as prose, and renders as ordinary Markdown with no tooling installed.
admdepends only onpython3from Arch'sbase, so a bare rebuild target can replay the runbooks with zero bootstrapping.- Room for the why — this decision record exists because the format has
somewhere to put it. A
configuration.nixdoes not.
Bad
- Containment is a convention, not an enforced invariant. Break it and
nothing errors; the repo silently degrades from a rebuild procedure into an
audit log.
adm factsis the only detector. - Idempotence is the author's responsibility, with no checker.
- The fence syntax is bespoke, so no other tool reads these runbooks.
- Two mental models to maintain: NixOS on the administering workstation, Arch here.
Confirmation
This decision holds only while all three of these are true. They are separate properties and should not be conflated — the second is cheap and says little, the third is the one that matters.
- Containment — every change to the box flows through a runbook.
Checked by
adm factsreporting no drift that isn't attributable to a runbook, at the end of every session. Ongoing. - Idempotence —
adm replayon an already-configured box is a no-op. Checked by a full replay finishing green with no duplicated state. Demonstrated 2026-10-18T06:19:04–06:19:10Z: all eight blocks of000-bootstrap.mdran in order against a configured box, every block and verify green, no spurious commit fromrepo-init. Seehosts/<box>/log/journal.md. - Reproduction —
adm replayonto a fresh Arch VM builds this box, finishing with a cleanadm verify. Never run. Still owed.
Check 2 proves the runbooks are re-runnable; only check 3 proves they are sufficient. Passing 2 is weak evidence for 3, because a block can silently depend on state the stock image doesn't have and still succeed here. Until 3 passes, reproducibility is a claim rather than a demonstrated property — NixOS would have made it true by construction, and we chose to owe a test instead. Pay the debt.
Reopen this if
- The box becomes several boxes. Nix wins outright at
rea-dev-01..05and none of the reasoning above survives that change. - Bit-identical rebuilds on demand become a requirement rather than a nicety.
- A from-scratch rebuild is needed anyway and console access is available — at which point take option 5, not an in-place conversion.
002 — btrfs snapshots, not ZFS
- Status: accepted
- Date: 2026-10-18
- Deciders: ryan
Context and problem statement
We wanted snapshot capability: cheap rollback for development workflows, and
a way to undo catastrophes like a mistaken rm. The initial proposal was to
migrate the root filesystem to ZFS, in place, on a single disk, from what was
believed to be an ext4 root — using a RAM-based SSH-accessible rescue
environment to shrink the filesystem offline.
The premise was mistaken: root was already btrfs, with
compress-force=zstd:3, not ext4:
/dev/sda3 btrfs rw,compress-force=zstd:3,discard=async,space_cache=v2,subvolid=5
So the requested capability was already present and unused. That reframed the question from "how do we migrate to ZFS" to "is ZFS worth migrating to, given snapshots are already available".
Decision drivers
- Snapshots with cheap rollback, and file-level recovery.
- Administration assumes no hypervisor console, so any procedure that can leave the box unbootable is expensive.
- Upgrades must keep working unattended on a rolling release.
- One disk, and no second disk assumed available.
Considered options
- Use btrfs snapshots, already available.
- ZFS root, single disk: shrink in place, kexec rescue environment, migrate.
- ZFS root on a second virtual disk.
- ZFS for data only, keeping btrfs root.
Decision outcome
Option 1. Implemented in
runbooks/040-btrfs-snapshots.md:
snapper with a tuned retention policy and timers, plus snap-pac for
automatic pre/post snapshots around every pacman transaction.
The decisive argument is not that btrfs is better than ZFS — it is not,
feature for feature. It is that ZFS is not in Arch's repositories. It
ships via AUR or the third-party archzfs repo as an out-of-tree DKMS module,
and OpenZFS supports new kernels on a lag. On a rolling release that makes
every pacman -Syu a chance to install a kernel ZFS cannot build against —
and with ZFS as root, that means an unbootable box.
The migration risk was explicitly accepted, and would have been acceptable;
this is a different thing. It is a recurring risk, on every upgrade, forever,
on a machine with no console. Avoiding it would require pinning to
linux-lts with IgnorePkg, reintroducing precisely the staleness that
000 exists to prevent.
Two further points settled it:
- Space reclamation is a dead end. ZFS cannot expand a vdev backward into freed space. Adding the old partition as a second top-level vdev yields a stripe across two partitions on one disk: no redundancy gain, and losing either partition destroys the pool. Worse than the status quo, permanently.
- It would reverse #001. That record cites btrfs snapshots covering system and data rollback as a reason Arch was sufficient. Migrating to ZFS to obtain snapshots re-solves a solved problem, and trades an in-tree filesystem for a kernel-coupled out-of-tree one.
Pros and cons of the other options
| option | assessment |
|---|---|
| 2. ZFS root, single disk | The only option that needed a rescue environment, and the only one ending in an unfixable partition geometry. Note the irony: the offline-shrink apparatus was required by ext4. btrfs shrinks online, so most of that machinery was unnecessary even on its own terms. |
| 3. ZFS root, second disk | Much better than option 2 — clean full-disk vdev, no shrinking, no stripe, no orphaned space. Still carries the DKMS-versus-rolling-kernel risk on the root filesystem. Viable if a second disk is attached. |
| 4. ZFS data-only, btrfs root | The best ZFS option, and not rejected on merit. Datasets, properties, send/recv and zvols, with a version mismatch costing a mounted pool rather than the ability to boot. The path to take if ZFS features are wanted later. |
Consequences
Good
- Zero migration risk: nothing was repartitioned, and no reboot was required.
- In-tree filesystem, official packages, no DKMS, no kernel coupling.
- Snapshots are near-free: copy-on-write, on an already-compressed filesystem.
snap-pacmakes a bad upgrade a readable diff rather than an archaeology project.
Bad
- No whole-system rollback. Root is mounted from the btrfs top level
(
FS_TREE,subvolid=5) rather than an@subvolume, so the snapper rollback idiom — make a snapshot the default subvolume — has nothing to swap. File-level restore works; "roll the whole box back to 09:00" does not. Fixing it means moving root into@, which deserves its own decision. - Snapshots are not backups. Same partition, same filesystem, same disk. No off-box target exists yet, so this is the largest remaining gap. Superseded on this point by #003: backup is handled at the hypervisor layer, so this is a division of labour rather than a gap.
- Forgoing ZFS features that btrfs genuinely lacks: zvols, per-dataset
properties and quotas, ARC, and better
send/recvergonomics.
Confirmation
adm verify 040passes, including a precondition asserting root is btrfs.040'ssnapshot-roundtripblock proves the capability end to end rather than asserting configuration: it writes a canary, snapshots, deletes it, restores from the snapshot, compares content, and cleans up. It runs on every replay, so the feature cannot silently rot.snap-pacobserved producing a pre/post pair for a realpacmantransaction, with the command line recorded as the description.
On converting an ext4 root to btrfs
Not needed here, and not implemented, but assessed because the repo aims
to be generic. It is materially safer than ext4 → ZFS would have been:
btrfs-convert works in place with no partition surgery, keeps the same
PARTUUID, and has a real undo in btrfs-convert --rollback (the original
metadata is preserved in an ext2_saved subvolume). And btrfs is in-tree, so
the recurring kernel-coupling risk disappears entirely.
What remains genuinely hard:
- Root must be unmounted, so an SSH-reachable pre-root environment is still
required. Everything needed is in official repos —
mkinitcpio-netconf,mkinitcpio-dropbear,mkinitcpio-tinyssh,mkinitcpio-systemd-tool,kexec-tools— but note this box uses thesystemdinitramfs hook, so the commonnetconf+dropbearrecipe (written for the busyboxencryptflow) does not apply unmodified. - The result is untidy: chunk layout inherited from ext4, no
@subvolume layout, no retroactive compression, andext2_savedholding space until deleted — which forfeits the rollback.
Preferred alternatives, in order: provision btrfs in the first place
(Arch's own arch-boxes images are btrfs, which is where this box came from);
or copy-based migration — create btrfs on free space or a second disk, rsync
across, switch root — which avoids btrfs-convert altogether and yields a
clean @ layout that in-place conversion cannot produce.
Any conversion procedure added later must be labelled untested until it has been exercised against a throwaway ext4 VM. We have no ext4 box here, so shipping it unlabelled would mean the first person to run it is the test.
Reopen this if
- zvols, per-dataset properties, or
zfs sendergonomics become load-bearing — then take option 4, data-only, on a second disk. - A second virtual disk is attached, which makes option 3 reasonable and removes every geometry objection above.
- Whole-system rollback becomes a requirement. That is a btrfs subvolume layout problem, not a filesystem choice problem, and does not need ZFS.
003 — Backups live at the hypervisor layer
- Status: accepted
- Date: 2026-10-18
- Deciders: ryan
Context and problem statement
#002 set up btrfs snapshots and listed the
absence of off-box replication as the largest remaining gap, with btrbk or
btrfs send as the implied future work. That framing was wrong: backup is
already handled, one layer down.
Decision
Backups are taken at the hypervisor layer. In-guest btrfs snapshots are for convenience only — fast, fine-grained undo — and carry no durability guarantee.
Decision drivers
- Backup belongs wherever it can be verified and restored independently of the thing being backed up. A guest cannot sensibly guarantee its own recovery from guest loss.
- Avoid two half-solutions to one problem.
Consequences
Good
-
The two tiers are complementary, with no overlap to maintain:
tier covers does not cover hypervisor whole disk, ESP, partition table, host loss, off-box fine-grained or instant recovery; invisible from inside the guest btrfs snapshots instant file-level undo, pre- pacmanstate, in-guestdisk failure, guest loss, anything outside the root filesystem -
The hypervisor tier covers what btrfs structurally cannot.
/efiis a separate vfat partition that no btrfs snapshot can reach, and the partition table itself is outside any filesystem. Both are inside a whole-disk image. -
Two items are now explicitly off the roadmap, which is worth more than it sounds — each was a non-trivial piece of work implied by #002:
- Off-box
btrfs send. Would duplicate the hypervisor's job, less well. - Moving root into an
@subvolume. Its only purpose was whole-system rollback, and hypervisor rollback is strictly more thorough — it reverts the disk, not one filesystem. Restructuring the subvolume layout to obtain a weaker version of an existing capability is effort for a worse result. TheFS_TREElayout therefore stays, and #002's main "bad" consequence is largely neutralised rather than outstanding.
- Off-box
-
Retention in
040was retuned to match the actual purpose: monthly and yearly timelines dropped to zero, weekly cut to 2. A six-month btrfs timeline would imply a durability guarantee this tier does not provide.
Bad / to keep in mind
- Hypervisor snapshots of a running VM are crash-consistent, not application-consistent. The disk is captured at an instant with no filesystem quiesce. btrfs tolerates this well — it is copy-on-write with atomic metadata commits, so a restored image looks like an unclean shutdown, which it handles. But if a database ever runs on this box, its own consistency is its own problem: it will need either a quiesce hook or its own dump, and a restored image may contain a torn transaction.
- The backup tier is not verifiable from inside the guest, so nothing in
this repo can assert it exists or works.
adm verifycannot cover it, and no verify block should pretend to. Restore testing lives with whoever operates the hypervisor. - This repo therefore documents a dependency it cannot check — worth stating plainly, since every other claim here is backed by a verify block.
Confirmation
Not confirmable from the guest, by construction. The honest check is a periodic restore test at the hypervisor layer — a backup that has never been restored is a hypothesis. That check is out of scope for this repo and is recorded here so the gap in coverage is visible rather than assumed away.
Reopen this if
- The hypervisor's backup or retention policy changes materially, or restore testing reveals it does not cover what is assumed here.
- This box starts hosting something with its own consistency requirements — a database, a queue — at which point crash-consistent whole-disk images stop being sufficient on their own.
- The box is ever migrated somewhere without a backup tier underneath it, in
which case off-box
btrfs sendreturns to the table immediately.
004 — A shareable repo with secrets kept in it
- Status: accepted; its publication caveat amended 2026-10-20, see the amendment and #007
- Date: 2026-10-18
- Deciders: ryan
Context and problem statement
The purpose of this box became explicit: it is a development workstation for
building an LLM-bootstrapped, self-documenting workflow that produces a
declarative configuration system for Arch Linux. The repo is not scaffolding
around the purpose — it is the product, and the box is both workbench and
first subject. (rea-dev is named for REA,
"Reverse Engineer Anything", intended to run here later in an agent harness;
not set up, and no runbook describes it.)
A product is meant to be shared. That collides with the fact that configuring a real machine involves real secrets, and with the earlier design in which every runbook simply hardcoded this box's values.
Decision
Three changes, together:
- Split generic from per-box. The product —
runbooks/,bin/adm,AGENTS.md,DECISIONS.md,.sops.yaml— lives at the root. Each machine gets a namespace directory holding only its own configuration and state:hosts/<box>/<box>.env,hosts/<box>/AS-FOUND.md,hosts/<box>/facts/,hosts/<box>/log/. One repo can describe several boxes;admselects the directory automatically when there is one, by$ADM_BOXor hostname when there are more. - Nothing host-specific in a runbook. Every value that is true of one
machine rather than of the procedure comes from
<box>.env. Host and port are derived from$GIT_REMOTErather than stored again, so there is one source of truth. - Secrets in the repo, encrypted with SOPS + age. The key lives on the box, outside the repo, at the location SOPS looks by default. Superseded by #005: there are now two tiers, and the key that can read everything deliberately does not live on any managed box.
Pros and cons of the encryption options
| option | assessment |
|---|---|
| SOPS + age | Chosen. Encrypts values, leaving keys readable, so git diff still shows which setting changed. age keys are one line — no keyring, no agent, no expiry. Both in official repos. |
age alone |
Whole-file. An opaque blob has no reviewable history, which forfeits the audit trail that makes this repo worth having. |
git-crypt |
Same whole-file diff problem, and upstream has been quiet for years. |
| plain GPG | Works; heavy key management, and agent behaviour on a headless box is a recurring source of mystery failures. |
The deciding factor was reviewable diffs, not cryptographic strength — all four are adequate there.
Consequences
Good
- Runbooks are now genuinely generic; standing up another box means adding one directory with one env file.
- Encrypted values never reach the remote in clear, and
admrefuses to export a value that is stillENC[...]rather than passing ciphertext into a block as configuration. 015re-encrypts a plaintext env file on every replay, so an accidental plaintext edit is repaired rather than pushed.
Bad
- SOPS encrypts dotenv comments. A self-documenting env file becomes a
wall of
#ENC[...]— worse than no comments, since the noise looks like data. The env file is therefore bareKEY=VALUEand all explanation lives in015and the README. - Two keys must stay plaintext (
BOX_NAME,ADMIN_USER) or the repo cannot bootstrap itself:admmust read them on a box wheresopsis not installed yet, which is every box at the start of000. Neither is secret. - The age key is plaintext on the box by necessity, since
adm replayruns unattended. Encryption changes the repo's exposure, not the box's. - Losing the box and sharing the repo loses the secrets. The key must also live in the password manager; nothing here can verify that it does.
Confirmation
015'ssecrets-roundtripasserts thatsops -dandadm's own loader agree on a decrypted value — not merely that decryption succeeds, but that what blocks see matches what the file holds.encrypt-env verifyasserts the file is encrypted, that the bootstrap keys are still readable, and that at least one value really is ciphertext — so an over-broadunencrypted_regexcannot silently turn encryption into theatre.adm replaypasses end to end in the new layout.
The caveat: this repo is not yet publishable
Encrypting <box>.env was necessary and is nowhere near sufficient.
- Git history already contains the plaintext.
GIT_REMOTEwas committed in clear earlier the same day, and the forge URL appeared in prose in several files before being parameterised. Encrypting a file does not retract what is already in history. hosts/<box>/facts/leaks the network.facts/network.txtrecords the box's address, gateway, DNS server and search domain;facts/users.txtrecords accounts;facts/etc-manifest.txtlists every file in/etc. These are committed in plain text by design, because their value is the reviewable diff — encrypting them would destroy the drift detection they exist for.
So the honest status: encryption protects against accidental exposure and makes the repo shareable with people who are already trusted. It does not make this repo publishable. Publication needs one of:
- extracting the generic half into a fresh repo with no history — clean, and natural now that the namespace split exists; or
- rewriting history and accepting that
facts/must then be excluded, encrypted, or scrubbed, each of which costs something real.
Not decided. Recorded so that "the secrets are encrypted" is never mistaken for "this can be made public".
Amendment 2026-10-20: publication is now a real goal
The reopen condition below has fired. #007 makes the generic half a public repository, which is what "a product is meant to be shared" was always reaching for. What follows corrects this entry where later work changed the facts, and leaves the rest standing.
The premise holds; only its status changes. The three decisions above are
unaffected. The generic/per-box split and "nothing host-specific in a runbook"
come out strengthened — the second turned out to have no enforcement at all,
and a block in 015 was carrying one box's blob shas inside a generic runbook
for weeks before anyone measured it (21529bd). That is an argument for the
rule, not against it.
The route is still open, and this entry's advice is still the safer half of
it. Taking the fresh-repo route "rather than trying to launder this history"
was written before hooks/scan-history existed. That tool now proves no
secret survives a filtered history, which weakens one objection — but not
the other. Measured today: the forge's hostname is absent from the working
tree and present in 4 commits touching generic paths, in prose written
before the remote was parameterised. A path-filter keeps those commits, and
scan-history will not flag them, because it checks env files for unencrypted
values and a hostname in a paragraph is neither. So the choice is a real one:
- fresh repo, no history — clean, loses the commit messages, which in this repo carry most of the reasoning; or
- path-filtered history plus a text replacement for the forge host — keeps the reasoning, and the replacement is the part that must be checked by reading rather than by a tool.
Deliberately still not decided here; it belongs to #12's execution, where the filtered result can be inspected before anything is pushed.
The second objection is gone. hosts/<box>/facts/ cannot leak into a
public repo under either route: a path filter keeps only generic paths, and
hosts/ is not one of them. Nothing needs excluding, encrypting or scrubbing
— the concern was correct for rewriting this repo in place, which is no
longer the plan.
Reopen this if
Publication becomes a real goal, at which point take the fresh-repo route rather than trying to launder this history.Fired 2026-10-20 — see the amendment above. The fresh-repo-versus-filter choice is narrower than this line assumed, and is #12's to make.- A second box is added, which is the first genuine test of whether the generic/per-box split actually holds.
- Secrets outgrow a dotenv — more than a handful of values, or anything needing rotation — at which point a real secret store beats a file.
005 — Two tiers of key: one operator, one per box
- Status: accepted
- Date: 2026-10-18
- Deciders: ryan
Context and problem statement
#004 put secrets in the repo encrypted with SOPS + age, with each box holding its own key. That left a question it did not answer: can one stored key decrypt every machine's configuration, including machines that do not exist yet?
The answer was "accidentally yes, for now". 015 took the union of every
recipient in a single creation_rules entry, and SOPS consults the recipient
list only at encryption time and never re-keys existing files. The result was
chronology-dependent access: each box could read the env of every box
created after it, and none of the ones created before. Neither isolation nor
a master key — an artefact of the order the machines happened to be built in.
Worse, the only universal recipient was rea-dev's own box key. The thing
that must never be lost was sitting in plaintext on a disposable VM, and a
compromise of that VM would hand over the key to every future machine's
secrets too.
Decision
Two kinds of identity, with different lifetimes and different reach:
| identity | lives | can decrypt |
|---|---|---|
| operator | the administering workstation at ~/.config/sops/age/keys.txt, plus the password manager. Never on a managed box. |
every box, including boxes not yet created |
| per-box | the box itself | only that box's own env file |
.sops.yaml is generated from operator.pub plus each
hosts/<box>/<box>.age.pub, with one creation_rules entry per box naming the
operator recipients and that box's key. There is deliberately no catch-all:
an unmatched .env makes SOPS refuse rather than encrypt to the wrong set.
Decision drivers
- One key, stored once, must open everything — including future machines.
- The recovery key must not sit on any disposable machine.
- A box has no business reading another machine's secrets.
adm replaymust decrypt that box's own configuration unattended.
Considered options
- Union of all recipients in one rule. The status quo being replaced.
- Per-path rules, operator plus that box. Chosen.
- Per-path rules, operator only — boxes hold no key at all.
- An external secret manager.
Pros and cons of the other options
| option | assessment |
|---|---|
| 1. Union | Looked fine and was not. Chronology-dependent access, and the universal recipient was a box key. Rejected on both counts. |
| 3. Operator-only | Simplest and most secure: a box could encrypt (recipients are public) but never read. Rejected because adm replay must decrypt $GIT_REMOTE unattended, and an operator-only design makes every replay an interactive operation. |
| 4. External manager | Right answer at a different scale. Adds a network dependency and a service to run for a handful of values, and the box would still need a credential to reach it — moving the problem rather than solving it. |
Consequences
Good
- One operator key, stored once, decrypts every box's configuration —
demonstrated, not assumed: the workstation decrypted
rea-dev.envholding no box key at all. - Per-box isolation is real, not nominal. Verified in a scratch repo: with one rule per box, each box key opened its own file and was denied on the other, while the operator opened both.
- Box keys are now disposable. A rebuild no longer needs the old box key restored, which removes a prerequisite from the rebuild procedure and a thing to back up.
- The recipient topology is declarative — the set of committed public keys —
and any box regenerates
.sops.yamlbyte-identically, so no box fights another over a tracked file.
Bad
path_regexcannot be templated, so every box needs its own rule. Mitigated by generating the file, but it means adding a box touches a shared tracked file.- A rebuilt box cannot decrypt its own configuration until an operator
re-keys it. The new key is not a recipient, and only someone who can read
the file can add one.
015'ssync-env-recipientsdetects this and prints the operator-side remedy, because it will happen mid-rebuild. - Adding a recipient never re-keys existing files;
sops updatekeysis required and must run somewhere that can already decrypt. - Losing the operator key is now the single unrecoverable failure. That is an improvement — it is one identified thing to protect — but it is absolute.
Confirmation
sops-rules verifyasserts every rule grants an operator recipient, that this box has a rule, and that no catch-all exists. A box becoming unrecoverable is precisely what the first of those prevents.sync-env-recipients verifycounts the recipients baked into the env file and compares them against the rule, so a file left un-re-keyed fails rather than lurking.box-recipient verifyasserts the published public key matches the private key actually on the box.- End-to-end: the operator key on the workstation decrypted
rea-dev.envaftersops updatekeysretrofitted it.
Reopen this if
- A second operator identity is wanted — a hardware key, or a colleague. Add a
line to
operator.pub, regenerate, andsops updatekeysevery existing env file; nothing else changes. - Boxes must stop being able to read their own secrets at rest, in which case take option 3 and accept interactive replays.
- The number of secrets or the need to rotate them outgrows a dotenv, in which case option 4 becomes worth its weight.
006 — The root filesystem stays top-level; no @, no true rollback
- Status: accepted
- Date: 2026-10-19
- Deciders: ryan
Context and problem statement
040 now brackets every adm invocation in
a snapshot pair, and adm checkpoint / adm restore give named, permanent
restore points. All of it is file-level: snapper undochange computes a
diff between two snapshots and copies files back.
What that cannot be is a rollback in the sense people mean when they say the
word — the VM-style "put the machine back as it was". That is
snapper rollback, which creates two snapshots, points btrfs's default
subvolume at a writable copy of the target, and reboots into it. It requires
the root filesystem to be a nested subvolume, conventionally @, so that
something outside it can be redirected. This box's root is subvolid=5 —
FS_TREE, the filesystem itself. There is nothing to point elsewhere.
So: migrate to @ and gain true rollback, or stay as we are?
Decision
Root stays top-level. No @ migration. Rollback on this box means
file-level restore, and whole-machine recovery stays at the hypervisor layer
where #003 already put it.
Decision drivers
- The migration is one-way, and its failure mode is a box that does not boot. It is normally done from rescue media, not from the running system it is rearranging.
@-rollback is worse than the tier we already have. A hypervisor snapshot covers the ESP, the partition table, and a box that will not POST;@covers one subvolume of one filesystem.- The only thing
@-rollback buys that file-level restore cannot is repairing an unbootable box from inside, becauseundochangeneeds a running system to execute in. On a VM, that case is already covered one layer down. - The image this box came from — Arch's own
arch-boxes— lays out root without@. Migrating means the rebuild procedure in README.md no longer matches what a fresh image gives you.
Consequences
Good
- Nothing risky happens, and the rebuild path keeps matching the stock image.
- Filters remain the exclusion mechanism, which is what makes exclusions
cheap. Every computed diff honours
/etc/snapper/filters/*.txt, so keeping/homeor a container volume out of a restore is a line in a file. Under@-rollback there is no diff to filter, so the same exclusions would have to exist as real subvolumes.
Bad
- No atomic rollback, and no boot-into-a-snapshot-and-commit.
050installsgrub-btrfsand there are snapshot boot entries, so a snapshot can be inspected from the boot menu — but it cannot be made permanent. Half that machinery is inert, by this decision rather than by accident. - Consistency after a restore is not guaranteed. Snapper says so itself; its example is undoing a user creation and leaving that user's files behind.
Confirmation
btrfs subvolume get-default /reportsID 5 (FS_TREE)andfindmntshowssubvol=/, sosnapper rollbackis structurally unavailable. Read off the layout rather than tested: the failure mode of testing it is an unbootable box, and there is nothing to learn that the layout does not already say.adm undoandadm restoreexist, are guarded, and have been exercised on a real pair.
Reopen this if
- This repo describes a box with no hypervisor beneath it. This is the case the whole argument turns on. A native install has no lower tier, so recovering an unbootable system from inside stops being redundant and becomes the only option available.
- A box is being provisioned from scratch anyway. The migration is nearly
free at install time and expensive afterwards. If a fresh machine is being
laid down, create
@and@homethen and this decision costs nothing to reverse.
If either happens, the ordering is not negotiable: /home, and anything
like /var/lib/docker, must become their own subvolumes before the @
migration, not after. Rolling back @ takes everything inside @ with it,
and a snapshot cannot exclude a directory — only a separate subvolume is
excluded, because snapshots do not recurse into child subvolumes. Do these in
the wrong order and the first rollback reverts a user's home directory. That
is why distributions shipping rollback hand you @ and @home together from
the installer; it is not a stylistic choice.
007 — The generic half becomes a public repo; machines are private forks
- Status: done 2026-10-20 (issue #12). Upstream is
admon GitHub; this repo is a fork of it, and its first commit is upstream's. - Date: 2026-10-20
- Deciders: ryan
Context and problem statement
#004 split generic from
per-box but left publication undecided, and its premise — "a product is meant
to be shared" — stayed aspirational. The collision became concrete once this
repo went private: anything under hosts/<box>/ is then readable only by
repo members, which is right, but if every machine repo is private the
generic half has no home. N copies, quietly diverging, and the runbooks stop
being the definition and become per-machine forks of one. That is the single
failure this project cannot absorb.
The export changed one half of the problem in the meantime. A published
document now carries its own sources and bin/adm, verified byte-for-byte on
every render, so the generic half can travel in one file to anyone holding a
link. That solved distribution. It did nothing for canonicity — being
able to hand someone a copy is not the same as there being an original.
Decision
The generic half becomes a public repository. Each machine repo is a
private fork of it, and pulls fixes with git merge upstream/master.
Considered and rejected
| option | why not |
|---|---|
| Fork | Chosen. One working tree, so adm verify still covers everything the box owns; git merge upstream/master needs no tooling; conflicts appear exactly where a machine edited a generic file, which is the signal you want. |
| Subtree | Would also keep one tree, and was the preferred answer while the generic half was assumed to live inside each machine repo. A fork makes it unnecessary — the whole tree already is the generic half plus one directory. |
| Submodule | Breaks the single-tree property adm verify depends on, and makes the generic half something the box references rather than owns. |
| No central home; drift found by comparing published documents | Distribution without canonicity. Each machine could prove what it has; none could say what is correct. |
Consequences
Good
- "The runbooks are the definition" becomes true rather than aspirational.
- Pulling upstream is plain git, and
adm verifyis a real acceptance test afterwards: 76 verify blocks name exactly whichadm runa merge has left owing. - A fork relationship is what makes merging work at all. A forge template starts an unrelated history and cannot merge later.
Bad
- Sending a fix upstream needs care. A commit made on a machine may touch both
halves, and pushing it as-is would put one box's data in a public repo. The
safe shape is to cherry-pick a generic-only commit onto a branch from
upstream/master. operator.pubholds this operator's public recipients. Not secret, not generic; the public repo needs an empty or example one.- The split is only as reliable as its enforcement, and there was none until issue #11.
Confirmation
- #11's verify — a runbook naming a specific box fails. It is the precondition for this decision being safe, not an improvement on it.
hooks/scan-historyover whatever history the public repo starts with. It scans every blob reachable from every ref and is the existing tool for proving nothing per-box survived.- The per-box allowance list moved out of the generic half in
21529bd; a public repo correctly starts with none.
Reopen this if
- Forks diverge enough that "one definition" stops describing reality — at which point the honest move is to say so rather than to keep merging.
- The generic/per-box boundary moves. Each move is a re-test of whether the split holds, and #11 is what makes a move visible.
What publication actually cost
The generic half was published as one commit, not as filtered history, and the reason is worth recording because the first attempt was wrong and public for ten minutes.
Filtering seemed safe: drop hosts/, operator.pub, *.env, confirm no
per-box path remains, confirm scan-history is clean. All of that passed, and
the result still carried one machine's network configuration, its account
list and a 24 KB inventory of its /etc. Per-box files had lived at three
spellings over the project's life — the repo root, then rea-dev/, then
hosts/rea-dev/ — and a filter written against the current layout keeps the
oldest. scan-history did not object because those files hold no secret by
its definition: they are facts, which is exactly why they are committed.
The verification was wrong in the same way the filter was, which is the part worth remembering. The paths checked for were the paths excluded, so the check confirmed the filter had done what it was told rather than that the result was clean. The question that would have caught it is which top-level paths have ever existed in this history — answerable in one command, and not asked.
A single commit has no such surface. The cost is the commit messages, which carried most of the reasoning; this file is where that reasoning lives instead, which is an argument for keeping it current rather than for keeping the history.
- Nobody ever forks it. A public repo with one user is a private repo with extra steps, and the reasoning above would not have been wrong, only moot.
000 — Bootstrap
Required — every box runs this.
Take a freshly provisioned Arch box to a documented baseline with this repo installed and running.
This runbook is generic: it should work on any stock Arch machine, and
nothing in it is specific to the box it happens to be running on. Values that
differ per machine live in <box>.env. For what one particular
box looked like when it was adopted — provenance, hardware, loose ends — see
AS-FOUND.md; that archaeology deliberately does not live
here.
Examples below use rea-dev for concreteness. Substitute your own.
Assumes
- A stock Arch install, reachable over SSH, with
pythoninstalled.bin/admis Python, so it cannot install its own interpreter:pacman -S pythonis the one command that has to be run before anything here can be. Arch'sbasedoes not include it. - A login that is root or can
sudo. Every privileged command is written with an explicitsudo, so either works — which matters becauseadmindoes not exist until010. - Working network and a usable mirror list.
- This repo already present at
/opt/admin.
That last point makes the runbook mildly self-referential: it sets up the
repo that contains it. On a real rebuild you get the repo onto the box first —
clone it from the remote configured in 020, or
rsync it from a workstation — and only then replay. Every block is
idempotent, so replaying against an already-bootstrapped box is a no-op.
Per-box values
adm exports every key in <box>.env into the environment of every block, so
runbooks refer to $BOX_NAME rather than hardcoding a name. Standing up
another machine means editing that one file.
test -n "${BOX_NAME:-}" || { echo "BOX_NAME unset -- is <box>.env present?"; exit 1; }
echo "BOX_NAME=$BOX_NAME ADMIN_USER=${ADMIN_USER:-unset}"
Which runbooks this box runs
Every runbook declares itself, in a fenced adm block directly under its
purpose line:
```adm
required
```
Required runs on every box. Optional runs only where the host env opts
in by number, through OPTIONAL_RUNBOOKS:
OPTIONAL_RUNBOOKS=050 070 080
A runbook declaring neither is unclassified, and adm will not run or
verify it. That is deliberate rather than defaulting either way: a new runbook
that silently ran everywhere and one that silently ran nowhere are both
failures, and only one of them is visible. adm list names it in red.
The flag is in the file rather than the filename. Filenames are referenced fifty-nine times across this repo and anchor every link in the export, so a rename to flip one runbook would rewrite its inbound links each time.
Silence is asymmetric, and that is the point
An optional runbook nobody opted into produces no output at all during a
run — not a warning, not a skip notice. With enough of them, per-run notices
about things deliberately not in use train people to stop reading adm run.
An opt-in naming a runbook that does not exist must fail loudly. Silence
there would hide a typo that disables a runbook the operator believes is
running, which is the dangerous direction. That check lives in a verify block
rather than only in adm run, so a misspelling fails on every adm verify
instead of waiting for a replay.
Not opting in also means not verified. 080's export-renderer asserts
cmark-gfm is installed; on a box that never enabled 080, that is a failure
reported for something deliberately absent. So adm verify skips them too,
which sharpens what DECISIONS #001's acceptance test
means: a clean verify covers every required runbook plus the optional ones
this box chose.
One invariant this adds, which nothing enforces
A required runbook must never depend on an optional one, because the optional one may not have run — and the bug would appear only on the boxes that did not opt in, which is the worst possible distribution.
Nothing checks it, and the obvious check does not work. Nine required runbooks already link to optional ones, every link a legitimate cross reference rather than a dependency, so scanning for references would report nine false positives and teach everyone to ignore it. Distinguishing "refers to" from "relies on" needs a human.
cd "$ADM_ROOT"
state() {
awk '/^```adm$/{f=1;next} f&&/^```$/{exit} f{print}' "$1" | tr -d '[:space:]'
}
bad=0 req=0 opt=0
for f in runbooks/*.md; do
case "$(state "$f")" in
required) req=$((req+1)) ;;
optional) opt=$((opt+1)) ;;
"") echo "$f declares neither required nor optional"; bad=1 ;;
*) echo "$f declares an unknown state: $(state "$f")"; bad=1 ;;
esac
done
test "$bad" = 0 || { echo "adm will not run or verify an unclassified runbook"; exit 1; }
# The asymmetry: an opt-in naming something absent is a typo that silently
# disables a runbook the operator believes is running.
missing=""
for n in ${OPTIONAL_RUNBOOKS:-}; do
hit="$(ls runbooks/ | grep -c "^$n-" || true)"
if [ "$hit" = 0 ]; then
missing="$missing $n(no such runbook)"
elif [ "$(state "runbooks/$(ls runbooks/ | grep "^$n-" | head -1)")" != optional ]; then
missing="$missing $n(not optional)"
fi
done
test -z "$missing" \
|| { echo "OPTIONAL_RUNBOOKS names:$missing"
echo "an opt-in must name a runbook that exists and declares itself optional"
exit 1; }
echo "$req required, $opt optional, ${OPTIONAL_RUNBOOKS:-none} opted in"
Every block a runbook declares has to be a block adm can see
A fence is closed by the first line that looks like a closing fence, and
parsing then resumes after it. Several blocks here write whole documents from
heredocs, and those documents contain fenced code of their own — so a stray
fence inside one truncates the block it is in and can swallow the blocks that
follow. Nothing complains. adm run and adm verify simply have fewer
blocks to work with than the file appears to declare, and both report success
over what is left.
That is not hypothetical and it was not a stranger's mistake. A section was
once inserted into 070 by anchoring on a heading that occurs twice — once
in that runbook's prose, once inside the skill document the runbook writes.
The fenced block went into the heredoc, agent-skill lost its closing fence
and its own verify, and adm verify 070 reported all blocks ok while running
eleven of thirteen.
cd "$ADM_ROOT"
PYTHONDONTWRITEBYTECODE=1 python3 - <<'PY'
import importlib.machinery as M, importlib.util as U
import io, os, re, sys
sys.argv = ["adm"]
loader = M.SourceFileLoader("a", "bin/adm")
mod = U.module_from_spec(U.spec_from_loader("a", loader))
loader.exec_module(mod)
# Column 0 only. An indented fence is an illustration inside prose, which is
# not a declaration and is not meant to run; a buried one is at column 0,
# because it was written to be a block and ended up inside another.
fence = re.compile(r"^```(?:bash|sh) id=(\S+)", re.M)
bad = []
for path in sorted(mod.all_runbook_paths()):
declared = fence.findall(io.open(path, encoding="utf-8").read())
parsed = [b.attrs["id"] for b in mod.parse_runbook(path)]
for ident in declared:
if ident not in parsed:
bad.append("%s#%s" % (os.path.basename(path), ident))
if bad:
print("declared at the start of a line, but not visible to adm:")
for b in bad:
print(" " + b)
print()
print("Each is inside another block's body -- almost certainly a heredoc")
print("that writes markdown. adm would skip it silently.")
raise SystemExit(1)
print("every declared block parses, across %d runbooks"
% len(mod.all_runbook_paths()))
PY
Adopting a box
A box is adopted by adm adopt <box>, which creates hosts/<box>/, writes
the env file with the keys that must be readable before anything can decrypt,
and appends the anchored negation to hosts/.gitignore.
It was prose until now, and prose is the one form this repo cannot check. It drifted exactly as you would expect: the README said two keys were enough to bootstrap for several hours after this runbook began requiring a third, so the documented path for a new machine was broken and nothing could notice.
The command does not restate the key list. It reads the runbooks for
${KEY:?...} assertions — the same text the runbooks themselves fail on — and
writes the ones it can supply, then reports the rest with the runbook that
will demand each. The instructions cannot disagree with the runbooks because
there is only one list.
Two details are load-bearing, both found by getting them wrong:
- The negation is committed, not worked around with
git add -f. Oncehosts/*ignores a directory, files created inside it later are invisible rather than untracked, soadm factswould write snapshotsgit statusnever mentions. - The pattern is anchored —
/*then!/<box>/. An unanchored*matches at every depth, so re-including the directory lets git descend into it and ignore its contents all over again.
Adopting as something
adm adopt <box> --from workstation
A profile is an env preset, not a separate set of runbooks. It carries the
opt-in list for a purpose plus whatever non-secret defaults those runbooks
want, and adm adopt instantiates it into hosts/<box>/<box>.env, recording
ADOPTED_FROM so a later reader can tell what was chosen. Separate runbook
sets per profile would break "the repo is one document in order" and bring
back the dependency problem; a preset selects, it does not fork.
The opt-in list for a purpose is itself knowledge. "A workstation wants user home subvolumes, boot-into-snapshots, the agent harness and the export" is a judgement someone made once. Without presets every new box re-derives it by reading the runbook directory and guessing.
Presets are named <name>.preset, not <name>.env, deliberately. 015's
commit hook refuses any *.env that is not SOPS-encrypted — the rule that
stops a machine's secrets reaching a remote in clear — and a preset must be
plaintext, because a fork has no key for it. Exempting templates/ from the
scanner would have been the easy fix and the wrong one: the file is not a box
env, so it should not take the name. The scanner's key-material check still
applies to it, as it does to every blob.
A preset may contain only keys the encryption policy exempts. That rule is
not invented for presets: .sops.yaml's unencrypted_regex is this repo's
definition of what is not a secret, and a preset is a plaintext file shipped
to every fork. So 070's LLM credentials and 080's publish keys cannot be
in one — the preset turns those runbooks on, and the operator supplies the
values with sops afterwards. A key-shaped value in a shipped preset is a
trap adm env-check could never catch, because the name would be perfectly
well formed.
Profiles are exclusive for now. --from workstation,docker-host raises
ordering and conflict questions immediately, and there is no evidence yet
about what they should be.
cd "$ADM_ROOT"
test -d templates || { echo "no templates/ directory"; exit 0; }
shopt -s nullglob
presets=(templates/*.preset)
test "${#presets[@]}" -gt 0 || { echo "templates/ holds no presets"; exit 0; }
unenc="$(awk -F\' '/^[[:space:]]*unencrypted_regex:/ {print $2; exit}' .sops.yaml)"
test -n "$unenc" || { echo "no unencrypted_regex in .sops.yaml"; exit 1; }
state() {
awk '/^```adm$/{f=1;next} f&&/^```$/{exit} f{print}' "$1" | tr -d '[:space:]'
}
bad=0
for p in "${presets[@]}"; do
while IFS= read -r line; do
case "$line" in ''|'#'*) continue ;; esac
[[ "$line" == *=* ]] || continue
k="${line%%=*}"
# The whole rule: a preset is plaintext and shipped, so it may carry only
# what the policy already says is not a secret.
grep -qE "$unenc" <<<"$k" \
|| { echo "$p sets $k, which the policy does not exempt"; bad=1; }
if [ "$k" = OPTIONAL_RUNBOOKS ]; then
for n in ${line#*=}; do
f="$(ls runbooks/ 2>/dev/null | grep "^$n-" | head -1)"
if [ -z "$f" ]; then
echo "$p opts into $n, which is not a runbook here"; bad=1
elif [ "$(state "runbooks/$f")" != optional ]; then
echo "$p opts into $n, which is not optional"; bad=1
fi
done
fi
done < "$p"
done
test "$bad" = 0 || exit 1
echo "${#presets[@]} preset(s), none carrying a secret, all opt-ins real"
The README's flag table, against the parser
README.md lists which flags adm run, adm replay and adm verify take.
It said -k worked on "any of them" while adm replay -k was an error — a
claim about a program, written beside the program, in a form the program
could not read. It is a fence now, and this compares it with --help.
Both directions fail. A flag the README claims and the parser rejects is the original defect; a flag the parser accepts and the README omits is how the first one started.
cd "$ADM_ROOT"
PYTHONDONTWRITEBYTECODE=1 python3 - <<'PY'
import io, re, subprocess
text = io.open("README.md", encoding="utf-8").read()
# Located by content, not position: every line is "adm <cmd> <flags...>", so
# adding a paragraph above the fence is not a silent breakage.
claims = {}
for block in re.findall(r"^```\n(.*?)^```\n", text, re.M | re.S):
rows = [l.split() for l in block.strip().split("\n") if l.strip()]
if (rows and all(len(r) >= 3 and r[0] == "adm" for r in rows)
and all(f.startswith("-") for r in rows for f in r[2:])):
claims = {r[1]: set(r[2:]) for r in rows}
break
if not claims:
raise SystemExit("README has no fenced flag table. It documented these in "
"prose once\nand the prose was wrong, which is why the "
"fence exists.")
# The usage line, not the options list. argparse prints every flag there in
# brackets, one spelling each -- the same spelling the README uses -- so it
# needs no regex over help text. Reading the options list instead is what the
# first version of this did, and a mis-escaped character class turned "-n,
# --dry-run" into a flag called --d.
bad = []
for cmd, want in sorted(claims.items()):
out = subprocess.run(["./bin/adm", cmd, "--help"],
capture_output=True, text=True).stdout
usage = out.split("options:")[0]
have = set()
for tok in re.findall(r"\[([^\]]+)\]", usage):
first = tok.split()[0]
if first.startswith("-"):
have.add(first)
have.discard("-h")
for f in sorted(want - have):
bad.append("%s does not accept %s, but README lists it" % (cmd, f))
for f in sorted(have - want):
bad.append("%s accepts %s, which README does not list" % (cmd, f))
if bad:
for b in bad:
print(" " + b)
raise SystemExit(1)
print("README's flag table matches the parser for %d command(s): %s"
% (len(claims), ", ".join(sorted(claims))))
PY
Requirement: vCPU capability
Checked first, because the cost of discovering it late is the whole run.
The agent harness in 070 is a Bun-compiled binary
that needs AVX2, and on a CPU without it the failure is not a clean error
— it spins at 100% for ever producing no output, which reads like a hang or a
network fault. Finding that out after a full system upgrade, a user, a remote
and six more runbooks is a waste of an hour and a reboot.
Hypervisors make this easy to get wrong. Proxmox defaults new VMs to the
kvm64 model, which is roughly x86-64-v1 — no AVX, no AVX2, not even
SSE4.2 or AES-NI. This box shipped that way, and 070 was blocked until the
VM's CPU type was changed to host.
The check reports the x86-64 microarchitecture level rather than listing
flags, because that is the vocabulary hypervisors and toolchains use.
REQUIRE_CPU_LEVEL defaults to 3 and can be lowered in <box>.env for a box
that genuinely does not need an agent — a deliberate exception rather than a
silent one.
It is exempt from encryption, which it has to be. This block runs before
015 has generated anything that could decrypt a value, so
an encrypted REQUIRE_CPU_LEVEL is an escape hatch that cannot be opened at
the only moment anyone reaches for it: a fresh box, refusing to start, on a
CPU the operator has already decided to accept. It was encrypted until a
bootstrap on such a box went looking for the hatch and found it locked.
have() { grep -qw "$1" /proc/cpuinfo; }
# Every flag must be present. Note: a bare `for ... || break` cannot express
# this -- a loop's exit status is the status of `break`, which is zero, so the
# first version of this check reported v2 on a CPU with no SSE4.2 at all.
all() { local f; for f in "$@"; do have "$f" || return 1; done; return 0; }
level=1
if all cx16 lahf_lm popcnt sse4_1 sse4_2 ssse3; then level=2; fi
if (( level == 2 )) && all avx avx2 bmi1 bmi2 f16c fma movbe xsave; then level=3; fi
if (( level == 3 )) && all avx512f avx512bw avx512cd avx512dq avx512vl; then level=4; fi
want="${REQUIRE_CPU_LEVEL:-3}"
model="$(grep -m1 'model name' /proc/cpuinfo | cut -d: -f2- | sed 's/^ //')"
echo "cpu: $model -> x86-64-v$level (require v$want)"
if (( level < want )); then
echo
echo "This vCPU is below the level this stack requires."
echo "In Proxmox: set the VM's CPU type to 'host' (or x86-64-v$want) and reboot."
echo "'kvm64', the default, is v1 and lacks AVX2, AES-NI and much else."
echo "A CPU type change needs a full stop/start, not a warm reboot from"
echo "inside the guest -- the vCPU model is set when the VM process starts."
echo "If this box genuinely needs no agent harness, set REQUIRE_CPU_LEVEL"
echo "in <box>.env to record that as a decision."
exit 1
fi
Boot environment
Reported early so a rebuild on different firmware is visible before it causes
confusion, and because 050 has a real requirement here.
Firmware is not the constraint. BIOS and UEFI both work; what matters is
which bootloader and where /boot lives. 050 drives GRUB, so a box
using systemd-boot gets everything except boot-into-snapshot. And 040/050
both depend on /boot being inside the root filesystem, so a snapshot carries
the kernel and initramfs that matched it — a UEFI install that mounts the ESP
at /boot instead of /efi breaks that, on otherwise identical hardware.
This block reports rather than dictates; the hard requirements live in the runbooks that actually need them.
if [[ -d /sys/firmware/efi ]]; then fw="UEFI"; else fw="BIOS (legacy)"; fi
loader="unknown"
if command -v grub-mkconfig >/dev/null && [[ -d /boot/grub ]]; then
loader="grub"
elif sudo bootctl is-installed >/dev/null 2>&1; then
loader="systemd-boot"
fi
# /boot inside the root filesystem is what lets a snapshot carry its kernel.
if boot_src="$(findmnt -no SOURCE /boot 2>/dev/null)"; then
root_src="$(findmnt -no SOURCE /)"
if [[ "$boot_src" == "$root_src" ]]; then boot_loc="same filesystem as /"
else boot_loc="SEPARATE ($boot_src)"; fi
else
boot_loc="inside the root filesystem"
fi
echo "firmware: $fw bootloader: $loader /boot: $boot_loc"
test "$loader" != "unknown" || {
echo "no recognised bootloader found; 050 cannot manage this box"; exit 1; }
Order matters: keyring, then full upgrade, then everything else
This is the one sequencing rule in the runbook, and getting it wrong on a stale image breaks the system.
Arch is a rolling release with no supported partial-upgrade path. Syncing the
package databases and then installing a single package (pacman -Sy foo)
links that package against libraries newer than the ones on disk. Never
-Sy without -u.
The keyring is the one documented exception. On an image that has sat for
months, some packages are now signed by keys it has never seen, and a full
upgrade aborts on signature errors before it can fix itself. Pulling
archlinux-keyring ahead of the upgrade is the sanctioned way out.
sudo pacman -Sy --needed --noconfirm archlinux-keyring
pacman -Qi archlinux-keyring | awk -F': +' '/^Version/ {print "keyring", $2}'
sudo pacman-key --list-keys >/dev/null
Now the full upgrade. On a long-stale image this is the step most likely to
need a human: read any .pacnew notices and intervention warnings it prints
rather than assuming --noconfirm handled everything.
sudo pacman -Syu --noconfirm
if pacman -Qu; then
echo "ERROR: upgrades still pending"
exit 1
fi
echo "no pending upgrades"
Reboot if the kernel was replaced
When an upgrade replaces the kernel, the running kernel's module tree is deleted from disk, and loading any module not already resident fails until reboot. The symptom is confusing and arbitrary, so check for it explicitly.
Deliberately not paired with an automatic reboot: rebooting is a decision, not a side effect of a verify. Expect this to fail after any kernel upgrade and to pass once rebooted.
running="$(uname -r)"
installed="$(pacman -Q linux | awk '{print $2}')"
if [[ -d "/usr/lib/modules/$running" ]]; then
echo "running kernel $running still has its module tree; no reboot required"
else
echo "REBOOT REQUIRED: running $running, installed $installed,"
echo "and /usr/lib/modules/$running is gone."
exit 1
fi
To reboot, deliberately: sudo systemctl reboot.
Host identity
Stock Arch images ship a placeholder hostname — arch, typically — and an
SSH client alias on your workstation does not change what the box calls
itself. The mismatch is invisible until something that reports its own
hostname confuses you: logs, mail, certificates, monitoring, prompts.
Setting it is cheap, but it is a rename, so it is a separate decision from the rest of the bootstrap. If the box is already named correctly this block is a no-op.
if [[ "$(hostnamectl --static)" != "$BOX_NAME" ]]; then
sudo hostnamectl set-hostname "$BOX_NAME"
fi
hostnamectl --static
test "$(hostnamectl --static)" = "$BOX_NAME" \
|| { echo "static hostname is '$(hostnamectl --static)', expected '$BOX_NAME'"; exit 1; }
echo "hostname is $BOX_NAME"
Admin tooling
Kept deliberately small — just what this repo needs to function. Editors, language toolchains and services each earn their own runbook, so the reason for a package being present is written down next to it rather than accumulating in one anonymous list.
| package | why |
|---|---|
git |
version control for this repo |
rsync |
moving files on and off the box |
jq |
JSON wrangling in later runbooks |
sops |
decrypt the secret values in <box>.env — see 015 |
age |
the encryption backend SOPS uses here |
python |
bin/adm is written in it, so nothing here runs without it |
python is in that list and used to be explained as absent, with the reason
that it comes with Arch's base. It does not. base is a package of 38
dependencies and none of them is Python; there is no base group at all:
$ pacman -Qi base | grep -c python
0
Every box this repo has ever run on had Python because a Proxmox cloud-init
image installs it as a dependency — of the service 030
exists to switch off. On this box it was never an explicitly installed
package, which means pacman -Rns cloud-init, a reasonable thing to do once
it has been disabled, was a plausible route to removing the interpreter the
whole repository runs on.
Installing it here does not make it available — it has to be there already,
since adm is what runs this block. It makes it owned: an explicit
install reason, so nothing takes it away as an unused dependency, and a line
in a table saying why it is on the box.
The conclusion the old note drew is still right, for a better reason. adm
is written against the standard library, so it needs the interpreter and
nothing else — no virtualenv, no packages, no bootstrapping. That is why the
bootstrap-critical keys in <box>.env are left unencrypted: adm has to
read them before this block has run.
sudo pacman -S --needed --noconfirm git rsync jq sops age python
# --needed skips a package already present, but asking for it by name is what
# marks it explicitly installed. On a box where Python arrived as a
# dependency, that is the whole point of naming it here, so make the install
# reason certain rather than hoping -S set it.
sudo pacman -D --asexplicit python >/dev/null
for p in git rsync jq sops age-keygen python3; do
command -v "$p" >/dev/null || { echo "missing: $p"; exit 1; }
done
# Explicitly installed, not merely present. `command -v python3` succeeded on
# this box for a year while the package was only a transitive dependency of
# cloud-init, which is exactly the state that makes it removable without
# anything objecting. Presence is what adm needs today; ownership is what
# keeps it here.
pacman -Qe python >/dev/null 2>&1 \
|| { echo "python is installed but not owned: it is a dependency of"
echo "something else, so removing that something takes bin/adm's"
echo "interpreter with it. Fix: sudo pacman -D --asexplicit python"
exit 1; }
echo "git $(git --version | awk '{print $3}'), rsync, jq, sops $(sops --version --disable-version-check 2>/dev/null | awk '{print $2}'), age, python $(python3 -V | awk '{print $2}') present and owned"
Initialise the repo
The HEAD guard matters: this block makes the initial commit only. Without
it, every replay would sweep whatever happened to be uncommitted into a commit
labelled "initial commit" — which is precisely what it did on its first
revision here, mislabelling an unrelated change. Committing ongoing work is a
human decision with a human message.
cd "$ADM_ROOT"
if [[ ! -d .git ]]; then
git init -q -b master
fi
# The commit identity, set on the repository rather than on a user. See below
# for why; it is here because the commit this block makes is the first thing
# that needs it, and because an identity for a repository cannot be written
# before the repository exists.
git config --local user.name "$BOX_NAME admin"
git config --local user.email "admin@$BOX_NAME"
# This one is genuinely per-user: it decides the branch name of repos not yet
# created, so it has nowhere else to live. Belt and braces either way -- the
# init above names master explicitly -- but an operator typing `git init` by
# hand gets the same answer.
git config --global init.defaultBranch master
if ! git rev-parse --verify -q HEAD >/dev/null; then
git add -A
git commit -q -m "admin: initial commit of runbooks and adm runner"
fi
git log --oneline -1
cd "$ADM_ROOT"
test -d .git || { echo "no git repo at $ADM_ROOT"; exit 1; }
# That master is the trunk, not that you are standing on it. Work happens on
# branches -- `adm land` requires one -- and a check that fails for being
# somewhere else is testing where you stand rather than what it claims to.
git rev-parse --verify --quiet master >/dev/null \
|| { echo "no master branch; master is this repo's trunk"; exit 1; }
echo "master: $(git log --oneline -1 master) (HEAD on $(git rev-parse --abbrev-ref HEAD))"
Checking that adoption held
adm adopt ran before this runbook did, and this is where its work is
checked rather than where it happens. The check lives here, among the system
steps, because it asks git whether the box directory is visible -- which needs
the repository the block above just created. It sat in the preflight group
until a fresh box proved it could not: on a machine bootstrapping for the
first time, it ran fourteen blocks before anything had made a repository for
it to ask. Every box after the first one hid the problem, because the repo was
already there from last time.
cd "$ADM_ROOT"
box=admadopttest
test ! -e "hosts/$box" || { echo "hosts/$box exists; clean it up first"; exit 1; }
cleanup() {
rm -rf "hosts/$box" "hosts/${box}2"
if [ -f hosts/.gitignore ]; then
grep -v -E -x -- "!/${box}2?/" hosts/.gitignore > hosts/.gitignore.tmp \
&& mv hosts/.gitignore.tmp hosts/.gitignore
fi
git rm -q --cached -r --ignore-unmatch "hosts/$box" "hosts/${box}2" >/dev/null 2>&1 || true
git restore --staged hosts/.gitignore >/dev/null 2>&1 || true
}
trap cleanup EXIT
adm adopt "$box" >/dev/null || { echo "adm adopt failed"; exit 1; }
# Two box directories now exist, so adm must be told which one it is running
# as. Naming it rather than relying on the hostname also keeps this honest on
# a machine whose hostname does not match its box.
export ADM_BOX="$BOX_NAME"
env="hosts/$box/$box.env"
test -f "$env" || { echo "$env not created"; exit 1; }
test "$(grep -c "^BOX_NAME=$box\$" "$env")" = 1 \
|| { echo "BOX_NAME is not $box"; exit 1; }
# Required keys, read from inside bash blocks only. Grepping the raw markdown
# instead found KEY, from the paragraph above that describes the pattern:
# prose is not code, and adm does not treat it as code either.
keys() {
python3 - "$@" <<'PY'
import io, re, sys
for path in sys.argv[1:]:
fence = False
for line in io.open(path, encoding="utf-8"):
if line.startswith("```"):
fence = not fence
continue
if fence:
# Comment lines skipped, exactly as _required_env_keys skips them.
# This check compares adopt against that derivation, so the two
# have to agree about what counts -- and when they agreed by
# sharing a defect, a key invented by a comment passed both.
if line.lstrip().startswith("#"):
continue
for k in re.findall(r"\$\{([A-Z][A-Z0-9_]*):\?", line):
if not k.startswith("ADM_"):
print(k, path)
PY
}
# A key required before anything on the box can decrypt has to be in this
# file: there is no other way to supply it, because the age key does not
# exist yet. Which runbook that boundary falls at is derived, not named -- the
# one defining the sops-rules block.
#
# Excluding this runbook is not a detail. A check that greps the runbooks is
# itself a runbook, so whatever it searches for, it finds in its own text.
# Three drafts of this block failed that way in a row: on ".sops.yaml", on the
# word KEY in the paragraph above, and on the very block id below. $ADM_RUNBOOK
# is what adm exports for exactly this -- the file the running block lives in.
sops_rb="$(grep -l 'id=sops-rules' runbooks/*.md \
| grep -vF -- "$ADM_RUNBOOK" | sort | head -1)"
test -n "$sops_rb" \
|| { echo "no runbook defines sops-rules, so the point where encryption"
echo "becomes available cannot be located"; exit 1; }
early=""
for rb in $(ls runbooks/*.md | sort); do
[ "$rb" \< "$sops_rb" ] || break
early="$early $rb"
done
test -n "$early" || { echo "no runbooks sort before $sops_rb"; exit 1; }
# An earlier draft of this listed ADMIN_USER, ADMIN_UID and BOX_NAME, which
# restates the list it exists to derive -- the exact drift adopt was written
# to make impossible, reproduced inside its own test. It passed while 010
# required a key adopt had never heard of.
missing=""
while read -r k rb; do
grep -q "^$k=" "$env" || missing="$missing $k($(basename "$rb"))"
done < <(keys $early | sort -u)
test -z "$missing" \
|| { echo "adopt wrote no value for keys needed before encryption exists:"
echo " $missing"; exit 1; }
# Every other required key must at least be accounted for: written, or named
# in what adopt tells the operator to add later. One it has never heard of is
# the drift this exists to catch.
report="$(adm adopt "${box}2" 2>&1)" || { echo "$report"; exit 1; }
unknown=""
while read -r k _; do
grep -q "^$k=" "$env" && continue
grep -q "^ $k " <<<"$report" || unknown="$unknown $k"
done < <(keys $(ls runbooks/*.md | grep -vF -- "$ADM_RUNBOOK") | sort -u -k1,1)
test -z "$unknown" || { echo "adopt neither wrote nor reported:$unknown"; exit 1; }
# The trap: the directory must be visible to git, or facts written into it
# later are invisible rather than merely untracked.
! git check-ignore -q "hosts/$box/" \
|| { echo "hosts/$box is ignored; the negation did not take"; exit 1; }
printf 'probe\n' > "hosts/$box/probe.txt"
git status --porcelain -- "hosts/$box/probe.txt" | grep -q . \
|| { echo "a file inside hosts/$box is invisible to git status"; exit 1; }
grep -q -x -- "!/$box/" hosts/.gitignore \
|| { echo "hosts/.gitignore has no anchored negation for $box"; exit 1; }
adm adopt "$box" >/dev/null 2>&1 \
&& { echo "a second adopt of the same box succeeded"; exit 1; }
cleanup
trap - EXIT
test ! -e "hosts/$box" || { echo "cleanup left hosts/$box behind"; exit 1; }
grep -q -x -- "!/$box/" hosts/.gitignore \
&& { echo "cleanup left the negation in hosts/.gitignore"; exit 1; }
echo "adopt supplies every key the pre-encryption runbooks require, leaves the box visible to git, and refuses a second time"
Git commit identity
Commits made on the box need an author. This is the box's identity, not a person's: a commit authored here means "done on this machine", which is exactly what you want to see when reading the history later.
So it is set on the repository, not on a user, and the difference is not
cosmetic. It used to be git config --global, with this runbook claiming
that root got it during bootstrap and admin got it afterwards and both
ended up with the same values. Nothing made that true. admin does not
exist until 010, so on a fresh box only root was ever
configured, and the first commit attempted after the pivot failed with
Author identity unknown — on a box whose entire record is its commits.
Repo-scoped is also the only answer that stays right as the box gains users.
MACHINE_USERS may be real people with their own git identities; a
--system setting would rename their commits, and a --global one has to be
repeated for each of them and remembered for the next. The repository is the
thing the identity describes, so the repository is where it goes.
What repo-scoped gives up is that .git/config is not cloned, so a rebuild
that clones from the remote arrives without an identity. That is why
repo-init sets it on every run rather than only when it creates the repo:
a second run on a cloned repo does nothing else, and sets this.
cd "$ADM_ROOT"
# --local only, deliberately. `git config user.name` would answer from the
# global config too, so it would pass for any user who happened to have one
# set -- which is exactly the state that looked fine for root and failed for
# admin. Reading only the repository's own config asks the real question.
test "$(git config --local user.name)" = "$BOX_NAME admin" \
|| { echo "the repo has no commit identity of its own; run repo-init"; exit 1; }
test "$(git config --local user.email)" = "admin@$BOX_NAME" \
|| { echo "the repo's commit email is not admin@$BOX_NAME"; exit 1; }
test "$(git config --global init.defaultBranch)" = "master"
# Whoever is running this must actually be able to commit. The identity being
# present and the identity being usable are different claims, and it was the
# second one that broke.
git -C "$ADM_ROOT" var GIT_AUTHOR_IDENT >/dev/null \
|| { echo "git cannot resolve an author for $(id -un) here"; exit 1; }
echo "commit identity: $(git config --local user.name) <$(git config --local user.email)> (as $(id -un))"
Put adm on PATH
sudo ln -sfn "$ADM_ROOT/bin/adm" /usr/local/bin/adm
test "$(readlink -f /usr/local/bin/adm)" = "$ADM_ROOT/bin/adm"
command -v adm
Record the resulting state
Snapshot the box into hosts/<box>/facts/, establishing the baseline that every later
adm facts diffs against. From here on, anything that changes outside a
runbook shows up as a reviewable diff.
adm facts
Next
Filename order is replay order, and a runbook may depend only on what is numbered below it. Numbering steps by 10 leaves room to insert.
| runbook | what it does | needs |
|---|---|---|
010-admin-user.md |
create the unprivileged account that owns this repo, and stop administering as root | — |
020-admin-repo-remote.md |
push this history somewhere that is not the box it describes | 010 |
030-disable-cloud-init.md |
stop cloud-init re-provisioning a box that is already provisioned | 010, 020 |
040-btrfs-snapshots.md |
snapper and snap-pac — cheap undo for routine mistakes |
010 |
050-grub-btrfs.md |
GRUB entries to boot a snapshot when the box will not boot | 040 |
030 must come after 020 for a non-obvious reason worth knowing before a
rebuild: cloud-init is what installs root's authorized_keys, which 010
copies to admin. Disable it too early and a fresh box comes up with no way
in. 040 must come after 010 because it grants an ACL to the admin user,
and setfacl refuses an account that does not exist.
Still undocumented: what this box is actually for. Everything above is scaffolding — the account, the remote, the snapshots. No runbook yet describes the service this machine exists to run, which is the one that will matter most to whoever reads this repo next.
010 — The admin user
Required — every box runs this.
Create the unprivileged account that owns this repo and runs the runbooks.
Why not just use root
The box was adopted as root, which worked but left two problems.
Explicitness. When everything runs as root, nothing in a runbook says
which commands actually need privilege — the reader cannot tell
pacman -S nginx (needs root) from git log (does not). Running as admin
and writing sudo in front of the privileged commands makes the privilege
boundary part of the document. That is worth more here than it would be
elsewhere, because this repo's whole claim is that reading it tells you what
the box is.
Repo ownership. If admin owns /opt/admin but adm runs as root, git
refuses to operate on the tree (detected dubious ownership) and any file a
block writes lands root-owned inside an otherwise user-owned repo. One owner
for the repo, and privilege acquired per-command, avoids the whole class of
problem.
A useful consequence: because root may always sudo, every block written
with explicit sudo works whether root or admin runs it. The runbooks
are user-agnostic, which matters on a rebuild where admin does not exist
yet — see the pivot note below.
Identity and privilege
admin gets no password at all (authentication is by SSH key) and
passwordless sudo. Those two facts are the same decision: a password on an
account whose entire purpose is unattended administration is a secret to
manage with no corresponding security gain, since the SSH key already grants
full access and sudo would only re-prove the same thing.
Privilege is granted by one explicit file in /etc/sudoers.d/, not by
membership in wheel. Arch's convention is wheel plus an uncommented line
in /etc/sudoers, but that splits the grant across two places and makes it
invisible unless you think to check group membership. One named file is
greppable and says exactly what it does.
The uid is pinned by $ADMIN_UID rather than left to useradd, which would
take the next number above UID_MIN. On this box that reliably produced 1000
— but only because AS-FOUND.md records that the
image shipped no non-root accounts, so the admin user was always the first.
That is a dependency on the base image, not a decision, and it was nowhere
written down as the reason.
It matters because this account owns $ADM_ROOT, its own home, the age key
and the deploy key. A rebuild that numbered it differently would leave a
restored archive's files owned by nobody in particular, and hosts/<box>/facts/
would report the change as drift without explaining it. The group is pinned to
the same number, so uid == gid and neither depends on creation order.
ADMIN_UID is readable in the clear in <box>.env for the same reason
ADMIN_USER is, and the reason is specific: this runbook runs before
015 generates the box's age key, so at this moment nothing
on the machine can decrypt anything. Whatever this block needs has to be
plaintext or it cannot be read at all.
: "${ADMIN_USER:?ADMIN_USER unset -- is <box>.env present?}"
: "${ADMIN_UID:?ADMIN_UID unset -- is <box>.env present?}"
# Refuse rather than renumber. Changing the uid of the account that owns
# $ADM_ROOT, the age key and the deploy key means rewriting every owner on
# disk, which is a manual job and not something a replay should start.
if id -u "$ADMIN_USER" >/dev/null 2>&1 \
&& [[ "$(id -u "$ADMIN_USER")" != "$ADMIN_UID" ]]; then
echo "$ADMIN_USER exists with uid $(id -u "$ADMIN_USER") but <box>.env"
echo "declares ADMIN_UID=$ADMIN_UID."
exit 1
fi
owner="$(getent passwd "$ADMIN_UID" 2>/dev/null | cut -d: -f1 || true)"
if [[ -n "$owner" && "$owner" != "$ADMIN_USER" ]]; then
echo "uid $ADMIN_UID already belongs to '$owner', not '$ADMIN_USER'"
exit 1
fi
if ! id -u "$ADMIN_USER" >/dev/null 2>&1; then
getent group "$ADMIN_USER" >/dev/null 2>&1 \
|| sudo groupadd --gid "$ADMIN_UID" "$ADMIN_USER"
sudo useradd --uid "$ADMIN_UID" --gid "$ADMIN_USER" \
--create-home --shell /bin/bash \
--comment "$ADMIN_USER -- owns $ADM_ROOT, runs the runbooks" "$ADMIN_USER"
fi
# No password, ever. Key auth only; '!' is a locked hash, not an empty one.
sudo passwd -l "$ADMIN_USER" >/dev/null
home="$(getent passwd "$ADMIN_USER" | cut -d: -f6)"
sudo install -d -m 0755 -o "$ADMIN_USER" -g "$ADMIN_USER" "$home"
id "$ADMIN_USER"
id "$ADMIN_USER"
test "$(id -u "$ADMIN_USER")" = "$ADMIN_UID" \
|| { echo "uid is $(id -u "$ADMIN_USER"), <box>.env declares $ADMIN_UID"; exit 1; }
test "$(id -g "$ADMIN_USER")" = "$ADMIN_UID" \
|| { echo "gid is $(id -g "$ADMIN_USER"); uid and gid are pinned equal"; exit 1; }
test "$(getent passwd "$ADMIN_USER" | cut -d: -f7)" = "/bin/bash"
# Locked password: field is '!' or starts with '!'
sudo awk -F: -v u="$ADMIN_USER" '$1==u {print $2}' /etc/shadow | grep -q '^!' \
|| { echo "$ADMIN_USER password is not locked"; exit 1; }
echo "$ADMIN_USER exists, shell ok, password locked"
Passwordless sudo
Validated with visudo -c before installation. A syntax error in
/etc/sudoers.d/ makes sudo refuse to run at all, which on a box managed
without console access is how you lose administrative access permanently.
Never write this file in place.
tmp="$(mktemp)"
cat > "$tmp" <<EOF
# $ADMIN_USER owns $ADM_ROOT and runs its runbooks; see $ADM_ROOT/AGENTS.md
# Passwordless because administration is unattended and SSH key auth
# already grants equivalent access -- see runbooks/010-admin-user.md
$ADMIN_USER ALL=(ALL:ALL) NOPASSWD: ALL
EOF
sudo visudo -cqf "$tmp" || { echo "sudoers syntax check FAILED, not installing"; rm -f "$tmp"; exit 1; }
sudo install -m 0440 -o root -g root "$tmp" "/etc/sudoers.d/$ADMIN_USER"
rm -f "$tmp"
f="/etc/sudoers.d/$ADMIN_USER"
test "$(sudo stat -c '%a %U:%G' "$f")" = "440 root:root"
sudo visudo -cqf "$f"
sudo -n -u "$ADMIN_USER" sudo -n true \
&& echo "$ADMIN_USER has passwordless sudo" \
|| { echo "$ADMIN_USER cannot sudo without a password"; exit 1; }
Inbound SSH keys
admin inherits the same authorized keys root was adopted with, copied
rather than re-listed so no key material lives in this repo. On a rebuild
root's authorized_keys arrives from the image's cloud-init, so this still
works.
The copy direction is deliberate: edit root's keys (or cloud-init) and replay,
rather than editing admin's by hand — a replay would overwrite hand edits
here.
home="$(getent passwd "$ADMIN_USER" | cut -d: -f6)"
sudo install -d -m 0700 -o "$ADMIN_USER" -g "$ADMIN_USER" "$home/.ssh"
sudo install -m 0600 -o "$ADMIN_USER" -g "$ADMIN_USER" \
/root/.ssh/authorized_keys "$home/.ssh/authorized_keys"
sudo ssh-keygen -lf "$home/.ssh/authorized_keys"
home="$(getent passwd "$ADMIN_USER" | cut -d: -f6)"
want="700 $ADMIN_USER:$ADMIN_USER"
test "$(stat -c '%a %U:%G' "$home/.ssh")" = "$want" \
|| { echo "$home/.ssh is $(stat -c '%a %U:%G' "$home/.ssh"), expected $want"; exit 1; }
test "$(stat -c '%a %U:%G' "$home/.ssh/authorized_keys")" = "600 $ADMIN_USER:$ADMIN_USER"
n=$(sudo ssh-keygen -lf "$home/.ssh/authorized_keys" | wc -l)
test "$n" -ge 1 || { echo "no authorized keys for $ADMIN_USER"; exit 1; }
echo "$ADMIN_USER has $n authorized key(s)"
Root keeps no SSH identity of its own
Root has no ~/.ssh/config and no private key, and must never acquire
either: outbound SSH from this box (the git remote, in 020) belongs to
admin. Root's authorized_keys is left alone — that is inbound access
granted by the image, not an identity root holds.
This verify block is an assertion about something that should stay absent, which is the only kind of guard that catches a future runbook quietly doing the convenient thing.
Every test here goes through sudo, and that is not incidental. /root is
mode 0750, so an unprivileged test ! -e /root/.ssh/config succeeds because
the path is unreadable, not because it is absent — the guard would report
"root has no ssh config" while root happily had one. An absence assertion is
only meaningful if the asserting process can actually see the thing it claims
is missing. This block read as passing for exactly that wrong reason until a
replay as admin exposed it.
sudo test -d /root/.ssh || { echo "/root/.ssh missing entirely -- unexpected"; exit 1; }
! sudo test -e /root/.ssh/config || { echo "root has an ssh config; it should not"; exit 1; }
found=$(sudo find /root/.ssh -maxdepth 1 \( -name 'id_*' -o -name '*.pem' \) -print)
test -z "$found" || { echo "root holds private key material: $found"; exit 1; }
echo "root has no ssh config and no private keys (checked with privilege)"
Hand the repo to admin
sudo chown -R "$ADMIN_USER:$ADMIN_USER" "$ADM_ROOT"
sudo git config --system --add safe.directory "$ADM_ROOT"
ls -ld "$ADM_ROOT"
safe.directory is set system-wide so that if anyone does run git here as
root — during recovery, say — it works rather than failing confusingly. It is
a safety net, not an invitation.
The check below covers the repo root, .git, and every tracked file. It
deliberately excludes hosts/<box>/log/runs/, which is gitignored scratch output owned by
whoever ran adm — when this runbook is executed by root during a rebuild,
that block's own log files are necessarily root-owned, and failing on them
would make the check unpassable on the one path where it matters.
cd "$ADM_ROOT"
want="$ADMIN_USER:$ADMIN_USER"
test "$(stat -c '%U:%G' .)" = "$want" || { echo "repo root not owned by $ADMIN_USER"; exit 1; }
test "$(stat -c '%U:%G' .git)" = "$want" || { echo ".git not owned by $ADMIN_USER"; exit 1; }
bad=$(git ls-files -z | xargs -0 stat -c '%U %n' \
| awk -v u="$ADMIN_USER" '$1 != u { print $2 }' | head -5)
test -z "$bad" || { echo "tracked files not owned by $ADMIN_USER:"; echo "$bad"; exit 1; }
echo "$ADM_ROOT: root, .git and $(git ls-files | wc -l) tracked files owned by $ADMIN_USER"
The pivot
This runbook is the point where administration stops being root's job.
Everything numbered above it was written to run either way; everything below
it assumes admin.
On a rebuild that means: run 000 and 010 as root (or via any account that
can sudo), then switch to admin and run the rest. Connect as
ssh admin@rea-dev from here on.
Disabling root SSH login entirely is the obvious follow-up, and is
deliberately not done here — it should not happen until admin logins
have been confirmed working, and it deserves its own runbook so the lockout
risk is considered on its own terms.
echo "runbooks run as: $(id -un)"
sudo -n true && echo "sudo: passwordless ok"
015 — Secrets in the repo, encrypted
Required — every box runs this.
Let this repo be shared without leaking what it knows.
The point of this repo is to be a shareable declarative configuration system, which collides with the fact that configuring a real machine involves real secrets. The usual answers are both bad: keep secrets out of the repo and the repo no longer describes the box, or keep them in and the repo can never leave the building.
So: secrets live in the repo, encrypted at rest, with the key living only on the machines entitled to read them.
Why SOPS and age
| option | why not |
|---|---|
age alone |
Encrypts whole files. An opaque blob has no reviewable history — git diff can only say "it changed", so you lose the audit trail that makes this repo worth having. |
git-crypt |
Transparent, but also whole-file, with the same diff problem; and upstream has been quiet for years. |
| plain GPG | Works, but key management is heavy and agent behaviour on a headless box is a recurring source of mystery failures. |
| SOPS + age | Encrypts values, leaving keys readable, so a diff still shows which setting changed. age keys are a single line, with no keyring, no agent and no expiry. Both are in Arch's official repos. |
That diff property is the deciding factor, not cryptographic strength — all four are fine on that axis.
What is and is not encrypted
.sops.yaml matches \.env$ at any depth, and sets unencrypted_regex so
a few keys stay plaintext. The list is in the sops-rules block below, as
unenc, and it used to be repeated here so a reader could see it without
scrolling — until the two needed changing together, which is the moment a
second copy stops being a convenience.
ADMIN_UID is exempt for the same bootstrap reason as ADMIN_USER, and the
reason is sharper than it looks: 010 runs before this
runbook generates the box's age key, so at the moment the admin account is
created there is nothing on the machine that could decrypt an encrypted value.
Anything 010 needs has to be readable in the clear or it cannot be read at
all.
REQUIRE_CPU_LEVEL is exempt for a sharper version of the same reason.
000's cpu-capability block names it as the way to
override a CPU check that is refusing to let the box start — and 000 runs
before this runbook exists to decrypt anything. Encrypted, it was an escape
hatch that could not be opened at the only moment anyone would reach for it.
This is not laziness. adm must read BOX_NAME and ADMIN_USER on a box
where sops is not installed yet — which is every box at the start of
000, since sops is installed by 000. Encrypting
them would make the repo unable to bootstrap itself. Neither is secret: one is
a hostname, the other a conventional account name.
PUBLISH_S3_ENDPOINT, PUBLISH_S3_BUCKET and PUBLISH_S3_PREFIX (080) are plaintext because none of them is a secret: a
regional DigitalOcean hostname, the bucket name, and the box name. Only the
access key and the secret key are. Encrypting the rest was the "encrypted
means secret" confusion again, and it had a concrete cost -- the export's leak
gate fired on the bucket name, because heading slugs derive
arch-declarative-markdown from the title Arch Declarative Markdown, so a
value that was encrypted appeared in the output and not in the source. The
gate was right; the classification was wrong.
FORGE_HOST_FINGERPRINT stays plaintext because a public host key fingerprint
is not a secret, and is useless without knowing the host — which is
encrypted.
MACHINE_USERS (045) is plaintext for the same reason and
one more. Account names are not secret, and hosts/<box>/facts/users.txt already
commits every one of them in the clear — see DECISIONS.md
#004 — so encrypting the list here would be theatre rather than protection.
The additional reason is structural: the correct value on a box with no people
on it is the empty string, and SOPS does not encrypt an empty dotenv value. It
writes MACHINE_USERS= and leaves it, which env-fully-encrypted then reports
as an unencrypted key — correctly, having no way to tell an exempt empty value
from a leaked one. That check found this the first time the key was added.
Everything else is encrypted, and adm refuses to export a value that is
still ENC[...] rather than passing ciphertext into a block as if it were
configuration.
No comments in the env file
SOPS encrypts dotenv comments along with values, so a commented,
self-documenting env file turns into a wall of #ENC[...] the moment it is
encrypted — strictly worse than no comments, because the noise looks like
data. The first version of this box's <box>.env had a careful header explaining every
key; all of it became ciphertext.
So the env file holds nothing but KEY=VALUE, and every explanation lives
here and in README.md where it stays readable. Keys are
descriptive enough to carry their own meaning, which is the only
self-documentation that survives encryption.
The key
One age identity, at the location SOPS looks by default, in the admin user's home — deliberately outside the repo, so cloning the repo conveys nothing.
key="$HOME/.config/sops/age/keys.txt"
install -d -m 0700 "$HOME/.config/sops" "$HOME/.config/sops/age"
if [[ ! -f "$key" ]]; then
age-keygen -o "$key" 2>/dev/null
fi
chmod 0600 "$key"
echo "recipient: $(age-keygen -y "$key")"
key="$HOME/.config/sops/age/keys.txt"
test -f "$key" || { echo "no age key at $key -- run 015"; exit 1; }
test "$(stat -c '%a %U' "$key")" = "600 $ADMIN_USER" \
|| { echo "age key has wrong mode/owner: $(stat -c '%a %U' "$key")"; exit 1; }
age-keygen -y "$key" >/dev/null || { echo "age key is unreadable"; exit 1; }
echo "age identity present, recipient $(age-keygen -y "$key")"
Back this key up somewhere other than this box
If the box dies and the repo has been shared, the encrypted values are
unrecoverable without this key. Proxmox backs up /home/admin as part of
the whole disk (DECISIONS.md #003), so this is not a
single point of failure in practice — but a hypervisor restore is a heavy way
to recover one line of text.
Put the recipient and the private key in the password manager. Nothing in this repo can check that you did, which is exactly why it is written down here.
Two tiers of key: operator and per-box
There are two kinds of identity, and the distinction is the whole security model:
| identity | lives | can decrypt |
|---|---|---|
| operator | the administering workstation at ~/.config/sops/age/keys.txt, plus the password manager. Never on a managed box. |
every box's env file, including boxes that do not exist yet |
| per-box | the box itself, at $HOME/.config/sops/age/keys.txt |
only that box's own env file |
The operator key is the one thing that must be backed up, and the one thing whose loss is unrecoverable. Keeping it off every managed box means a compromised or discarded VM never hands it over.
A per-box key exists so adm replay can decrypt that box's own configuration
unattended. It deliberately grants nothing else: a box has no business reading
another machine's secrets.
Why .sops.yaml is generated, not edited
An earlier version of this runbook kept a single rule listing the union of every recipient. That was wrong in a way worth recording, because it looked fine: SOPS consults the recipient list only at encryption time and never re-keys existing files, so the union produced chronology-dependent access — each box could read the env of every box created after it, and nothing could read the ones created before. Neither isolation nor a master key; just an artefact of the order the machines happened to be built in.
Per-path rules fix it, but path_regex cannot be templated, so every box
needs its own rule — and a rule naming a box's key requires that key's
public half, which that box alone knows. So each box commits its own
recipient to hosts/<box>/<box>.age.pub, and .sops.yaml is derived from
operator.pub plus those files.
That makes the recipient topology declarative: it is the set of committed public keys, and any box regenerates byte-identical output from them. No churn, and no box fighting another over a tracked file.
There is deliberately no catch-all rule. An .env with no matching rule
makes sops refuse rather than silently encrypt to the wrong recipients —
loud and recoverable, instead of quiet and not.
cd "${ADM_BOX_DIR:?ADM_BOX_DIR unset}"
# head -1, because a keyring can hold more than one key and age-keygen -y
# prints a recipient for each. `adm unlock` puts a restored box key there
# alongside the one this runbook generated, and without this the box's
# .age.pub was written with two recipients on two lines -- a file whose own
# comment says "Public key" and which sops-rules reads one line from.
recipient="$(age-keygen -y "$HOME/.config/sops/age/keys.txt" | head -1)"
install -m 0644 /dev/stdin "${BOX_NAME}.age.pub" <<EOF
# Public age recipient for this box, written by runbooks/015-secrets.md.
# Public key: safe to commit. The private half never leaves this machine.
$recipient
EOF
cat "${BOX_NAME}.age.pub"
cd "${ADM_BOX_DIR:?ADM_BOX_DIR unset}"
f="${BOX_NAME}.age.pub"
test -f "$f" || { echo "$f missing"; exit 1; }
want="$(age-keygen -y "$HOME/.config/sops/age/keys.txt")"
grep -qx "$want" "$f" \
|| { echo "$f does not contain this box's recipient"; exit 1; }
echo "$BOX_NAME recipient published: ${want:0:28}..."
Generate .sops.yaml
cd "$ADM_ROOT"
test -s operator.pub || { echo "operator.pub missing -- see the runbook"; exit 1; }
# An operator.pub extracted from a published document carries a marker, and
# this refuses while it is there. The recipient in such a file belongs to the
# operator of the machine that document described, and an operator recipient
# is added to every box's rule -- so keeping it silently grants that person
# the ability to decrypt this box. The box cannot tell whose key it is; the
# person unpacking can, which is why this stops rather than warns.
if grep -q "ADM-OPERATOR-UNVERIFIED" operator.pub; then
echo "operator.pub is marked UNVERIFIED and has not been checked."
echo
echo "It came from a published document. The recipient in it belongs to"
echo "that document's operator, and an operator recipient can decrypt every"
echo "box it is added to -- including this one, and including secrets that"
echo "do not exist yet."
echo
echo " if it is yours: remove the marker comments from operator.pub"
echo " if it is not: replace the recipient with your own first"
exit 1
fi
recips() { grep -vE '^[[:space:]]*(#|$)' "$1"; }
ops="$(recips operator.pub | paste -sd,)"
test -n "$ops" || { echo "operator.pub lists no recipients"; exit 1; }
unenc='^(BOX_NAME|ADMIN_USER|ADMIN_UID|FORGE_HOST_FINGERPRINT|MACHINE_USERS|OPTIONAL_RUNBOOKS|ADOPTED_FROM|REQUIRE_CPU_LEVEL|PUBLISH_S3_ENDPOINT|PUBLISH_S3_BUCKET|PUBLISH_S3_PREFIX)$'
tmp="$(mktemp)"
{
echo "# GENERATED by runbooks/015-secrets.md -- do not hand-edit."
echo "# Derived from operator.pub and each hosts/<box>/<box>.age.pub, so any box"
echo "# regenerates this file identically. One rule per box: the operator"
echo "# recipients can read every box, a box key reads only its own."
echo "# No catch-all: an unmatched .env makes sops refuse rather than"
echo "# encrypt to the wrong recipients."
echo "creation_rules:"
for pub in $(ls -1 hosts/*/*.age.pub 2>/dev/null | sort); do
box="$(basename "$(dirname "$pub")")"
key="$(recips "$pub" | head -1)"
test -n "$key" || continue
printf ' - path_regex: ^hosts/%s/%s\\.env$\n' "$box" "$box"
printf ' input_type: dotenv\n'
printf " unencrypted_regex: '%s'\n" "$unenc"
printf ' age: %s,%s\n' "$ops" "$key"
done
} > "$tmp"
if cmp -s "$tmp" .sops.yaml; then
echo ".sops.yaml already current"
else
install -m 0644 "$tmp" .sops.yaml
echo "regenerated .sops.yaml"
fi
rm -f "$tmp"
grep -cE '^ - path_regex:' .sops.yaml | sed 's/^/rules: /'
# A marked operator.pub must never survive into a working box: the whole
# point is that it stops the bootstrap rather than being carried past it.
grep -q "ADM-OPERATOR-UNVERIFIED" operator.pub 2>/dev/null \
&& { echo "operator.pub still carries the UNVERIFIED marker"; exit 1; }
cd "$ADM_ROOT"
grep -q "AGE_RECIPIENT_PLACEHOLDER" .sops.yaml \
&& { echo ".sops.yaml still contains a placeholder recipient"; exit 1; }
n_rules=$(grep -cE '^ - path_regex:' .sops.yaml)
n_ages=$(grep -cE '^ age:' .sops.yaml)
test "$n_rules" -ge 1 || { echo "no creation_rules in .sops.yaml"; exit 1; }
test "$n_rules" = "$n_ages" || { echo "$n_rules rules but $n_ages age lines"; exit 1; }
# Every rule must grant an operator recipient, or a box becomes unrecoverable.
while IFS= read -r op; do
missing=$(grep -cE '^ age:' .sops.yaml)
have=$(grep -E '^ age:' .sops.yaml | grep -cF "$op")
test "$have" = "$missing" \
|| { echo "operator recipient ${op:0:20}... missing from $((missing-have)) of $missing rules"; exit 1; }
done < <(grep -vE '^[[:space:]]*(#|$)' operator.pub)
# This box must have a rule of its own.
grep -qE "^ - path_regex: \^hosts/${BOX_NAME}/${BOX_NAME}\\\\\.env\\\$" .sops.yaml \
|| { echo "no rule for hosts/$BOX_NAME in .sops.yaml"; exit 1; }
# And no catch-all that would quietly swallow an unknown box.
grep -qE '^ - path_regex: \\\.env\$$' .sops.yaml \
&& { echo "a catch-all rule exists; an unknown .env would be mis-encrypted"; exit 1; }
echo "$n_rules rule(s), every one granting the operator, including $BOX_NAME"
Bring existing files up to date
Adding a recipient to .sops.yaml does not re-key files that are already
encrypted — SOPS consults the rules only when encrypting. An env file written
before the operator key existed stays readable only by its box, which is
precisely the failure this whole arrangement is meant to prevent.
sops updatekeys retrofits it, and must run somewhere that can already
decrypt the file. It is a no-op once the recipients match, so this is safe on
every replay.
On a rebuild this block is where the design pays off, and where it needs a hand. The rebuilt box generates a fresh identity, which is not yet a recipient of its own encrypted env file — so it cannot decrypt it, and therefore cannot re-key it either. The remedy is deliberately not to copy the operator key onto the box; it is to re-key from the operator workstation, which can read the file. The failure message below says exactly that, because this will happen mid-rebuild when nobody wants to reason it out.
cd "$ADM_ROOT"
f="$ADM_BOX_DIR/${BOX_NAME}.env"
test -f "$f" || { echo "no $f yet"; exit 0; }
if ! grep -q '^sops_version=' "$f"; then
echo "$f is not encrypted yet; encrypt-env will handle it"
exit 0
fi
if out="$(sops updatekeys -y "$f" 2>&1)"; then
printf '%s\n' "$out" | tail -3
else
printf '%s\n' "$out" | tail -4
echo
echo "This box cannot decrypt $f, so it cannot re-key it."
echo "Expected on a rebuild: the old box key is gone and the new one is not"
echo "yet a recipient."
echo
echo "Run this yourself, at a terminal on the box:"
echo " adm unlock"
echo
echo "It names the two private keys that can open the file and what each"
echo "one costs, then takes whichever you have. The operator key is used"
echo "for one command and never written to disk."
echo
echo "The old instruction here was to re-key from the operator workstation"
echo "instead, which works and needs a second machine with a clone and the"
echo "operator key -- an undocumented prerequisite in the middle of a"
echo "restore that the published document claims to be sufficient for."
exit 1
fi
cd "$ADM_ROOT"
f="$ADM_BOX_DIR/${BOX_NAME}.env"
# The block above exits 0 on a plaintext env, saying encrypt-env will handle
# it; this used to exit 1 on the same file, so the two disagreed about the
# same state and the verify won. On a first run the env is still plaintext
# here -- encrypt-env is four blocks further down -- so there are no
# recipients to compare and nothing this check can say.
#
# Skipping is only honest because the invariant is asserted elsewhere and not
# optionally: encrypt-env's own verify and env-fully-encrypted both fail if
# the file is still plaintext when they run. A skip with no such backstop
# would be this check quietly deciding not to do its job.
if ! grep -q '^sops_version=' "$f"; then
echo "$f is still plaintext; encrypt-env is what encrypts it, and its own"
echo "verify is what insists that it did. Nothing to compare yet."
exit 0
fi
# Count age recipients baked into the file; must match the rule for this box.
in_file=$(grep -cE '^sops_age__list_[0-9]+__map_recipient=' "$f")
in_rule=$(grep -A3 -E "^ - path_regex: \^hosts/${BOX_NAME}/" .sops.yaml \
| awk -F': ' '/^ age:/ {print $2}' | tr ',' '\n' | grep -c .)
test "$in_file" = "$in_rule" \
|| { echo "$f has $in_file recipients but its rule names $in_rule -- run sops updatekeys"; exit 1; }
echo "$f encrypted to all $in_file recipients named by its rule"
Keep the env file encrypted
This block is the safety net that makes the arrangement survive contact with
real use. If someone edits <box>.env in plaintext — entirely natural, since
the keys are readable and it looks like an ordinary dotenv file — the next
replay re-encrypts it rather than letting the plaintext reach a shared
remote.
Guarded on sops_version, the metadata SOPS appends, so an already-encrypted
file is left alone rather than double-encrypted.
cd "${ADM_BOX_DIR:?ADM_BOX_DIR unset -- no hosts/<box>/<box>.env found}"
env_file="${BOX_NAME}.env"
test -f "$env_file" || { echo "no $env_file in $ADM_BOX_DIR"; exit 1; }
if ! grep -q "^sops_version=" "$env_file"; then
echo "$env_file is plaintext; encrypting with sops"
sops -e -i "$env_file"
else
echo "$env_file already encrypted"
fi
# `grep -c` exits 1 when the count is zero, and these blocks run under
# pipefail, so on a freshly adopted box this line failed for reporting the
# number 0. Nothing was wrong with the file: adopt writes only the values adm
# must read before sops exists, and all of those are exempt by policy, so
# there was nothing to encrypt and encrypt-env said so by failing.
# [A-Z0-9_], not [A-Z_]. Without the digits this counted 5 of the 7
# encrypted values on the first box it ran on, silently omitting
# PUBLISH_S3_ACCESS_KEY and PUBLISH_S3_SECRET_KEY -- the two most sensitive
# entries in the file, left out because their names contain a 3. A count that
# under-reports exactly the values someone is auditing for is worse than no
# count. Nothing asserted it, which is why it went unnoticed: the assertion
# that every non-exempt key is encrypted belongs to env-fully-encrypted and
# is made per key, by name, so it was never fooled.
n=$(grep -cE "^[A-Z0-9_]+=ENC\[" "$env_file" || true)
echo "encrypted values: $n"
if [[ "$n" == 0 ]]; then
echo "none yet -- every key in this file is exempt by policy. Expected on a"
echo "box that has just been adopted and has no credentials yet; the first"
echo "'sops set' will change it."
fi
cd "${ADM_BOX_DIR:?ADM_BOX_DIR unset}"
env_file="${BOX_NAME}.env"
grep -q "^sops_version=" "$env_file" \
|| { echo "$env_file is NOT encrypted -- secrets would reach the remote in clear"; exit 1; }
# The bootstrap keys must remain readable, or a fresh box cannot start.
grep -qE "^BOX_NAME=${BOX_NAME}$" "$env_file" \
|| { echo "BOX_NAME is not readable in $env_file; bootstrap would break"; exit 1; }
grep -qE "^ADMIN_USER=" "$env_file" \
|| { echo "ADMIN_USER is not readable in $env_file"; exit 1; }
# There is deliberately no "and at least one value must be encrypted" here.
# A box that has just been adopted holds nothing but exempt keys, so such a
# check fails for a file that is exactly as the policy requires.
#
# There used to be one, reading `grep -qE "=ENC\["`, commented "or this is
# theatre". It passed on every file ever written, including an all-exempt one,
# because sops stores its own integrity hash as sops_mac=ENC[AES256_GCM,...].
# The check matched that line and never once looked at a key. Worth keeping as
# a comment: it read as the strictest assertion in the block.
#
# What it was reaching for -- no secret sitting in the clear -- is
# env-fully-encrypted's job, per key, using the same check_env the commit hook
# uses. That one cannot be satisfied by the MAC line.
echo "$env_file: encrypted, bootstrap keys still readable"
echo "encrypted values: $(grep -cE "^[A-Z0-9_]+=ENC\[" "$env_file" || true)"
Unlocking a rebuilt box: adm unlock
A rebuilt machine gets its env from the published document, encrypted, and cannot read a word of it. The file is encrypted to the box key — which went with the old machine — and to the operator key, which this runbook used to forbid copying onto a box. So the document arrived complete and unreadable, and finishing the restore meant a second machine.
adm unlock takes either key, from a person at a terminal. It refuses
without one, for the reason env-fill refuses: a key on a command line
is in the agent's context, its transcript, shell history and /proc. There
is no flag to supply it.
The difference between the two keys is explained where it is asked, not here, because here is not where anyone will be reading it:
- the box key opens this box alone. If you kept a backup of this
machine's own key, this is the one to use: the box becomes the box it was,
nothing is re-keyed, and no fleet-wide secret touches the disk. It is
appended to the keyring rather than replacing what
015generated, because that generated key is already a recipient of.sops.yamland of anything written since. - the operator key opens every box there is or will be. It is passed to
one
sops updatekeysin that process's environment — never a file — which re-keys the env to this box's current key. When the command returns the operator key is gone and the box reads its own secrets with its own key.
A key that is neither is refused by name, with nothing written. The public
half is derived through age-keygen -y - on stdin, so establishing which key
you pasted does not require writing it anywhere.
Adding a value: through sops, never to the file on disk
The obvious way to add a key is the wrong one, and it earns a section because the failure is loud in the wrong place and silent in the right one.
Opening hosts/<box>/<box>.env in an editor and typing NEW_KEY=value does not
produce a file with one plaintext entry among the ciphertext. It produces a
file sops will no longer open at all: on load it tries to decrypt every key
not matched by unencrypted_regex, reaches the plaintext one, and gives up.
Error decrypting tree: Error walking tree: Could not decrypt value: Input
string ... does not match sops' data format
So the editor never appears — and the value is sitting in the working tree in the clear, with nothing complaining. That is the wrong moment to find out. The hooks below are the net at commit and push time, but exposure starts when the value is written: anything that reads the working tree in between has already had it, and the only remedy left is to revoke the secret and issue a new one.
Add values through sops, which decrypts to a temporary file, lets you edit
that, and re-encrypts on save:
sops hosts/<box>/<box>.env
Or without an editor, keeping the value out of shell history and out of the
process list — sops set wants a JSON-encoded value, which is what the
printf is for:
read -rs TOK && printf '"%s"' "$TOK" | sops set --value-stdin \
--input-type dotenv --output-type dotenv \
hosts/<box>/<box>.env '["NEW_KEY"]'
unset TOK
encrypt-env above would repair such a file on the next replay, and the hooks
would refuse to commit it. Neither notices it now, which is what this block
is for — it is the only check in this repo that looks at the env file as it
currently sits on disk.
Changing the exemption list: adm reencrypt
.sops.yaml's creation_rules apply when a file is created. An existing
encrypted file carries its own copy of the policy in
sops_unencrypted_regex, and that copy is the one SOPS consults — so editing
.sops.yaml changes nothing about a file that already exists, and the two
drift apart in silence.
That cost a real bug. ADMIN_UID was added to the list above and then set,
and it came back encrypted, because sops set read the stale embedded policy;
an encrypted ADMIN_UID is unreadable by 010, which
runs before the age key exists. It would have failed on the next rebuild and
nowhere before it. What hid it for several commits was that the only key
exercising the drift until then was MACHINE_USERS, whose value was the empty
string — and SOPS does not encrypt an empty value.
adm reencrypt # no-op when the policy already matches
adm reencrypt -n # show what would change
adm reencrypt --force # rotate the data key regardless
It is a subcommand rather than a recipe in this prose because the manual form
has three checks worth doing, and nobody does three checks by hand twice. It
decrypts and re-encrypts through a pipe so no plaintext reaches disk, then
refuses to replace the file unless the decrypted content is byte-identical, the
recipient count is unchanged, and the new file actually records the policy it
was meant to adopt. env-fully-encrypted below detects the drift and names
this command as the fix, which is the pairing worth having: the check that
finds a problem should say what repairs it.
Recipients are a different question and not this command's. That is
sops updatekeys, driven by sync-env-recipients above.
cd "$ADM_ROOT"
# Reuse the hooks' own check rather than writing a second one. A divergent
# copy would eventually disagree with the code that actually blocks commits,
# and the weaker of the two would be the one you were trusting.
. hooks/secret-scan.sh
env_file="${ADM_BOX_DIR:?ADM_BOX_DIR unset}/${BOX_NAME}.env"
test -f "$env_file" || { echo "no $env_file"; exit 1; }
check_env "${BOX_NAME}.env" "$(unencrypted_regex)" "$(cat "$env_file")" \
|| { echo "a value in ${BOX_NAME}.env is in the clear on disk right now"
echo "it was almost certainly added by editing the file instead of"
echo "going through sops -- see 'Adding a value' in this runbook."
echo "treat the value as exposed: rotate it, do not just encrypt it."
exit 1; }
# The file carries its own copy of the exemption policy, and that copy is the
# one sops actually uses. `.sops.yaml`'s creation_rules apply when a file is
# *created*; an existing encrypted file is governed by its embedded
# sops_unencrypted_regex. So editing .sops.yaml changes nothing about a file
# that already exists, and the two can disagree silently.
#
# That is not theoretical. Adding ADMIN_UID to .sops.yaml and then setting it
# encrypted the value, because `sops set` consulted the stale embedded regex --
# and an encrypted ADMIN_UID is unreadable by 010, which runs before the age
# key exists. It looked like a working change for as long as nobody looked.
embedded="$(grep -m1 '^sops_unencrypted_regex=' "$env_file" | cut -d= -f2-)"
declared="$(awk -F\' '/^[[:space:]]*unencrypted_regex:/ { print $2; exit }' .sops.yaml)"
test -n "$embedded" || { echo "$env_file has no embedded unencrypted_regex"; exit 1; }
if [[ "$embedded" != "$declared" ]]; then
echo "$env_file was encrypted under a different exemption policy:"
echo " embedded in the file: $embedded"
echo " declared in .sops.yaml: $declared"
echo "sops will use the embedded one, so a key added now may be encrypted"
echo "against the rules. Re-encrypt the file under the current policy:"
echo " adm reencrypt"
exit 1
fi
echo "${BOX_NAME}.env: every key is encrypted or deliberately exempt"
echo "exemption policy matches .sops.yaml: $declared"
Keys that are misnamed, and keys nothing reads
Two ways an env file can be wrong while looking right, both silent until now.
A key that is not a shell identifier is unusable, not merely ugly. It is
accepted by the file, exported into every block, and unreachable from all of
them: bash reads ${my-key:-x} as ${my} with a default, so the value is
simply never seen. Tested rather than assumed — the key arrives in the
environment and the block cannot name it.
A key nothing in the repo reads is almost always a typo. This is the
nastier of the two, because the symptom points at the wrong thing. Write
ADMIN_USR and 010 fails with ADMIN_USER unset while
the env plainly contains a key that looks like it was set — so the failure
reads as a bug in the runbook rather than in what was typed.
adm env-check reports both, and suggests the near match when there is one,
which is what turns the second case from a puzzle into a correction.
Its notion of "read by something" is deliberately loose: the key name
appearing anywhere in bin/adm, a runbook, a hook or the top-level documents
is enough. A key mentioned in prose is a key somebody knows about, and the
alternative — tracking which keys are used — would flag every value that is
only read on a box with a feature this one does not have.
cd "$ADM_ROOT"
adm env-check
Git hooks: refuse to commit or push secrets in the clear
encrypt-env above repairs a plaintext env file on the next replay, which
is the wrong moment — by then it may already be pushed. Two hooks move the
check earlier.
pre-commit catches the mistake at the cheapest possible point, against
the staged content.
pre-push closes what pre-commit structurally cannot: commits made with
--no-verify, commits made before the hooks were armed, and commits that
arrived from another clone. It scans the commit range actually being
pushed — including blobs added and later removed inside that range, because
those still land in the remote's history, where no later cleanup reaches them.
Both source a single library, hooks/secret-scan.sh, so there is one
definition of what counts as a secret. Two copies of a security check drift,
and the copy that drifts is always the one nobody is watching.
All three files are written by the blocks below rather than merely referenced,
because a runbook that points at a file in its own repo is not reproducible
from the documentation — you would need this repo to rebuild it, which is
the opposite of the point. The files in hooks/ are materialised
consequences, tracked so they are reviewable and active in a fresh clone,
and labelled as overwritten on replay.
They are tracked rather than left in .git/hooks/, which is not
version-controlled: a hook there would be missing from every clone, absent
exactly when a new box is set up by someone who has not read this file.
core.hooksPath must still be set locally — git deliberately offers no way
for a repository to arm its own hooks, since cloning would then run the
author's code.
The shared library
cd "$ADM_ROOT"
install -d -m 0755 hooks
install -m 0644 /dev/stdin hooks/secret-scan.sh <<'LIB'
# Managed by runbooks/015-secrets.md -- hand edits are overwritten on replay.
#
# Sourced by hooks/pre-commit and hooks/pre-push. Defines checks and runs
# nothing by itself. Self-test: bash hooks/secret-scan.sh --self-test
SOPS_YAML=".sops.yaml"
# Bracketed characters so these patterns match a real key but NOT this line,
# which is what lets this file be committed at all. See the runbook.
KEY_PATTERNS='AGE-SECRET-KEY-[1]|BEGIN OPENSSH PRIVATE [K]EY|BEGIN [A-Z ]*PRIVATE KEY'
warn() { printf '%s\n' "$1" >&2; }
# Keys .sops.yaml deliberately leaves readable. Read, never duplicated, so
# the hooks cannot drift from the encryption rules.
unencrypted_regex() {
awk -F"'" '/^[[:space:]]*unencrypted_regex:/ { print $2; exit }' \
"$SOPS_YAML" 2>/dev/null
}
# check_env <label> <exempt-regex> <content> -> 0 clean, 1 problems
# A plaintext env holding nothing but exempt keys. `adm adopt` writes exactly
# this -- it must, because 015 has not run and nothing on the box can encrypt
# yet -- and the hooks that would object are not installed until 015 either,
# so committing adoption is both the documented next step and permanent. The
# blob then failed the scan on every publish thereafter, and the remedy was to
# allow it by sha, one box at a time, for three values the policy itself calls
# not-secret.
#
# Judged by content, not by sha: $ure *is* this repository's definition of
# what is not a secret, so a file containing only keys it names leaks nothing
# by that same definition. Conservative in two ways that matter. Any key the
# policy does not name fails, encrypted or not; and a single ENC[ value makes
# the file a sops file with its metadata missing rather than a plaintext one,
# which stays a failure -- without the MAC there is nothing to trust.
plain_env_all_exempt() {
local ure="$1" content="$2" line key
[[ -n "$ure" ]] || return 1
grep -q '^sops_version=' <<<"$content" && return 1
grep -q '=ENC\[' <<<"$content" && return 1
while IFS= read -r line; do
case "$line" in ''|'#'*) continue ;; esac
[[ "$line" == *=* ]] || continue
key="${line%%=*}"
case "$key" in sops_*) continue ;; esac
grep -qE "$ure" <<<"$key" || return 1
done <<<"$content"
return 0
}
check_env() {
local label="$1" ure="$2" content="$3" problems=0 line key val
if ! grep -q '^sops_version=' <<<"$content"; then
if plain_env_all_exempt "$ure" "$content"; then
return 0
fi
warn " $label: not SOPS-encrypted (no sops_version line)"
return 1
fi
while IFS= read -r line; do
case "$line" in ''|'#'*) continue ;; esac
[[ "$line" == *=* ]] || continue
key="${line%%=*}"; val="${line#*=}"
case "$key" in sops_*) continue ;; esac
if [[ -n "$ure" ]] && grep -qE "$ure" <<<"$key"; then continue; fi
if [[ "$val" != ENC\[* ]]; then
warn " $label: '$key' is not encrypted"
problems=1
fi
done <<<"$content"
return "$problems"
}
# scan_blob <label> <exempt-regex> <content> -> 0 clean, 1 problems
scan_blob() {
local label="$1" ure="$2" content="$3" rc=0
case "$label" in
*.env) check_env "$label" "$ure" "$content" || rc=1 ;;
esac
if grep -qE "$KEY_PATTERNS" <<<"$content"; then
warn " $label: contains private key material"
rc=1
fi
return "$rc"
}
# Two causes, two different fixes, and reaching for the wrong one is worse
# than doing nothing: encrypt-env on a hand-edited file encrypts the value
# that was already exposed and makes the problem look solved.
refuse() {
warn ""
warn "pre-$1: refusing to $1 secrets in the clear."
warn ""
warn " a key added by hand to an encrypted file -- the usual cause:"
warn " delete that line, then re-add the value through sops, which"
warn " encrypts on save: sops hosts/<box>/<box>.env"
warn " NOT encrypt-env -- it would encrypt what you already exposed."
warn ""
warn " a file that was never encrypted at all, no sops_version line:"
warn " adm run 015-secrets.md --only encrypt-env"
warn ""
warn " either way, a value that has been on disk in plaintext is exposed."
warn " rotate it. encrypting it afterwards hides it without un-exposing it."
warn ""
warn " inspect: sops -d --output-type dotenv hosts/<box>/<box>.env"
warn " override: ${REFUSE_OVERRIDE:-git $1 --no-verify}"
warn ""
}
self_test() {
local ure='^(BOX_NAME|ADMIN_USER)$' fails=0
local ok='BOX_NAME=b
ADMIN_USER=a
GIT_REMOTE=ENC[AES256_GCM,data:x,type:str]
sops_version=3.13.3'
local plain_value='BOX_NAME=b
GIT_REMOTE=ssh://git@example/r.git
sops_version=3.13.3'
local no_meta='BOX_NAME=b
GIT_REMOTE=ENC[AES256_GCM,data:x,type:str]'
# What adopt writes, before anything on the box can encrypt.
local adopted='# written by adm adopt
BOX_NAME=b
ADMIN_USER=a'
# Same shape, one key the policy does not name.
local adopted_plus='BOX_NAME=b
ADMIN_USER=a
FORGE_TOKEN=glpat-deadbeef'
# Only exempt keys, but one value is ciphertext and the metadata is gone:
# a sops file that lost its MAC, not a plaintext one. The exempt-key test
# alone would call this benign, so this is what makes the ENC[ guard in
# plain_env_all_exempt load-bearing rather than decorative.
local exempt_but_enc='BOX_NAME=b
ADMIN_USER=ENC[AES256_GCM,data:x,type:str]'
scan_blob "a.env" "$ure" "$ok" 2>/dev/null \
|| { warn "self-test FAILED: encrypted env rejected"; fails=1; }
scan_blob "a.env" "$ure" "$plain_value" 2>/dev/null \
&& { warn "self-test FAILED: plaintext secret accepted"; fails=1; }
scan_blob "a.env" "$ure" "$no_meta" 2>/dev/null \
&& { warn "self-test FAILED: env without sops metadata accepted"; fails=1; }
scan_blob "notes.txt" "$ure" "$(printf 'AGE-SECRET-KEY-%s' 1ABC)" 2>/dev/null \
&& { warn "self-test FAILED: private key accepted"; fails=1; }
scan_blob "notes.txt" "$ure" "harmless text" 2>/dev/null \
|| { warn "self-test FAILED: innocent file rejected"; fails=1; }
scan_blob "a.env" "$ure" "$adopted" 2>/dev/null \
|| { warn "self-test FAILED: plaintext env of only exempt keys rejected"; fails=1; }
scan_blob "a.env" "$ure" "$adopted_plus" 2>/dev/null \
&& { warn "self-test FAILED: plaintext env with a non-exempt key accepted"; fails=1; }
scan_blob "a.env" "$ure" "$exempt_but_enc" 2>/dev/null \
&& { warn "self-test FAILED: ciphertext without sops metadata accepted"; fails=1; }
(( fails )) && { warn "secret-scan self-test FAILED"; return 1; }
echo "secret-scan self-test ok (8 cases: encrypted, plaintext, no-metadata, key, innocent, all-exempt, all-exempt+secret, exempt-but-enc)"
}
# This file is sourced by the three hooks, and is also run directly: 015
# checks it with `bash hooks/secret-scan.sh --self-test`. That did nothing
# whatsoever until this line existed -- the file defined self_test, reached
# the end, and exited 0 -- so the check read as a test and was not one. The
# suite was covered anyway, because each hook carries the same dispatcher and
# those do run it; what was wrong was only the claim about where.
#
# Guarded on being executed rather than sourced, so sourcing this from a hook
# that was itself invoked with --self-test does not run the suite twice.
if [[ "${BASH_SOURCE[0]}" == "$0" && "${1:-}" == --self-test ]]; then
self_test
exit $?
fi
LIB
bash hooks/secret-scan.sh --self-test
cd "$ADM_ROOT"
test -f hooks/secret-scan.sh || { echo "hooks/secret-scan.sh missing"; exit 1; }
bash hooks/secret-scan.sh --self-test
pre-commit
cd "$ADM_ROOT"
install -m 0755 /dev/stdin hooks/pre-commit <<'PRECOMMIT'
#!/usr/bin/env bash
# Managed by runbooks/015-secrets.md -- hand edits are overwritten on replay.
# Scans STAGED content, which differs from the working tree exactly when
# someone has partially staged an edit.
set -uo pipefail
. "$(dirname "$0")/secret-scan.sh"
[[ "${1:-}" == --self-test ]] && { self_test; exit $?; }
rc=0
ure="$(unencrypted_regex)"
while IFS= read -r f; do
[[ -n "$f" ]] || continue
content="$(git show ":$f" 2>/dev/null)" || continue
scan_blob "$f" "$ure" "$content" || rc=1
done < <(git diff --cached --name-only --diff-filter=ACM)
(( rc )) && refuse commit
exit "$rc"
PRECOMMIT
hooks/pre-commit --self-test
cd "$ADM_ROOT"
test -x hooks/pre-commit || { echo "hooks/pre-commit is not executable"; exit 1; }
hooks/pre-commit --self-test
pre-push
Reads the refs being pushed from stdin, in git's documented
<local-ref> <local-sha> <remote-ref> <remote-sha> format. An all-zero
remote-sha means a branch the remote does not have yet, so the range becomes
"everything not already on a remote"; an all-zero local-sha is a ref
deletion, which pushes no content and is skipped.
Blobs are deduplicated by hash, so a file touched in thirty commits is read once.
cd "$ADM_ROOT"
install -m 0755 /dev/stdin hooks/pre-push <<'PREPUSH'
#!/usr/bin/env bash
# Managed by runbooks/015-secrets.md -- hand edits are overwritten on replay.
#
# Scans every blob introduced by the commits being pushed -- including ones
# added and later removed within the range, since those still reach the
# remote's history. Closes the gap pre-commit cannot: --no-verify, commits
# predating the hooks, and commits from another clone.
set -uo pipefail
. "$(dirname "$0")/secret-scan.sh"
[[ "${1:-}" == --self-test ]] && { self_test; exit $?; }
rc=0
ure="$(unencrypted_regex)"
declare -A seen=()
while read -r _local_ref local_sha _remote_ref remote_sha; do
[[ -n "${local_sha:-}" ]] || continue
[[ "$local_sha" =~ ^0+$ ]] && continue # ref deletion: no content
# A remote sha this clone does not have means the ranges cannot be computed:
# a new branch, or a force-push over history that was rewritten underneath
# it. The first version let `git rev-list` fail, scanned the empty list it
# produced, and allowed the push -- silently, and in exactly the case where
# scanning matters most. Fall back to everything reachable from the tip.
if [[ "${remote_sha:-}" =~ ^0+$ ]] \
|| ! git cat-file -e "${remote_sha}^{commit}" 2>/dev/null; then
mapfile -t commits < <(git rev-list "$local_sha")
else
mapfile -t commits < <(git rev-list "$remote_sha..$local_sha")
fi
(( ${#commits[@]} )) || { echo "pre-push: no commits resolved; refusing" >&2; exit 1; }
for c in "${commits[@]}"; do
[[ -n "$c" ]] || continue
while IFS= read -r f; do
[[ -n "$f" ]] || continue
blob="$(git rev-parse "$c:$f" 2>/dev/null)" || continue
[[ -n "${seen[$blob]:-}" ]] && continue
seen[$blob]=1
content="$(git cat-file blob "$blob" 2>/dev/null)" || continue
scan_blob "$f" "$ure" "$content" || rc=1
done < <(git diff-tree --no-commit-id --name-only -r "$c")
done
done
(( rc )) && refuse push
exit "$rc"
PREPUSH
hooks/pre-push --self-test
cd "$ADM_ROOT"
test -x hooks/pre-push || { echo "hooks/pre-push is not executable"; exit 1; }
hooks/pre-push --self-test
scan-history
pre-push gates what leaves for the forge. A published git bundle leaves by
a different door: adm publish reads the object store's credentials from the
env file and uploads directly, so nothing about a push is involved and the
pre-push hook never runs. Someone who commits a secret and publishes without
pushing would export it, and a bundle carries all history -- including a
blob added and removed again, which stays reachable from the commit that
introduced it.
So the same library gets a third caller, scanning everything a bundle would carry rather than one push's worth of commits.
That difference in scope brings a problem the push hook never has to face.
unencrypted_regex is the list of names exempt from encryption, and it
changes as the repo learns what it needs -- MACHINE_USERS was called
HUMAN_USERS for a while, and before the move to hosts/ the env file was a
template at the repo root. pre-push only ever scans the commits in one push,
so older history is grandfathered by the simple fact of never being looked at
again. A bundle ships all of it, so every past commit gets judged by today's
list, and a variable renamed years later fails retroactively. Rewriting
history to satisfy a policy it predates is not a reasonable answer.
The answer is an explicit list of blobs already examined and found harmless, each with its reason. It is keyed by blob sha, which is the safe key: a sha is its content, so an approved entry cannot later be made to hold something else. Allowing a path would grant a hiding place; allowing a sha grants exactly one known string of bytes. Adding an entry means editing this runbook and saying why, which is the point -- the judgment ends up written down next to the rule it bends, not buried in a config file.
The answer is an explicit list of blobs already examined and found harmless, each with its reason. It is keyed by blob sha, which is the safe key: a sha is its content, so an approved entry cannot later be made to hold something else. Allowing a path would grant a hiding place; allowing a sha grants exactly one known string of bytes.
That list is per box, at hosts/<box>/scan-history.allow, and it is
written by hand rather than by a block here. It records judgments about
this repository's history, which no other machine shares — a fork starts
with none and accumulates its own. It lived in this runbook for a while, with
two blob shas from one repository's history inside it, which made a generic
runbook carry a single box's data: exactly what AGENTS.md
forbids, and nothing was checking.
Hand-written, like each box's AS-FOUND.md and for the same reason: it is a
record of decisions a person made, not a state a machine can converge on.
Replaying this runbook must not invent or discard one.
cd "$ADM_ROOT"
# No assertion that the file exists -- a repo whose history is clean has
# nothing to allow, and that is the better state to be in. What is asserted
# is that any entry is a full 40-character sha. A short one would still match
# the grep in scan-history, but as a prefix, and a prefix allows more than the
# single blob that was actually reviewed.
bad=0; files=0
for f in hosts/*/scan-history.allow; do
[ -e "$f" ] || continue
files=$((files+1))
while read -r sha _; do
case "$sha" in ''|'#'*) continue ;; esac
[[ "$sha" =~ ^[0-9a-f]{40}$ ]] || { echo "$f: not a full blob sha: $sha"; bad=1; }
done < "$f"
done
test "$bad" = 0 || exit 1
# And that no copy was left behind at the old generic path, which would be
# read by nothing and would quietly stop being maintained.
test ! -e hooks/scan-history.allow \
|| { echo "hooks/scan-history.allow still exists; it is now per-box"; exit 1; }
echo "$files per-box allowlist(s), every entry a full sha"
cd "$ADM_ROOT"
install -m 0755 /dev/stdin hooks/scan-history <<'SCANALL'
#!/usr/bin/env bash
# Managed by runbooks/015-secrets.md -- hand edits are overwritten on replay.
#
# Scans every blob reachable from every ref: the whole of what `git bundle
# --all` would carry. Not a git hook -- git has no "before you publish" event
# -- so `adm publish` calls it directly, and it fails closed.
set -uo pipefail
. "$(dirname "$0")/secret-scan.sh"
[[ "${1:-}" == --self-test ]] && { self_test; exit $?; }
# Per box, because these are judgments about one repository's history and no
# other machine shares them. Concatenated rather than chosen: a repo has one
# adopted box, and reading them all avoids needing to know which.
allowed() { cat hosts/*/scan-history.allow 2>/dev/null; }
# There is no --no-verify to suggest here; publish is not a git command.
REFUSE_OVERRIDE="review the blob, then add its sha to hooks/scan-history.allow
via runbooks/015-secrets.md#scan-history-allow"
rc=0
ure="$(unencrypted_regex)"
declare -A used=()
# rev-list --objects prints "<sha> <path>", and the path may contain spaces,
# so the path is taken as the rest of the line rather than as a field.
while read -r blob path; do
[[ -n "$blob" && -n "$path" ]] || continue
content="$(git cat-file blob "$blob" 2>/dev/null)" || continue
out="$(scan_blob "$path" "$ure" "$content" 2>&1)" && continue
if allowed | grep -qE "^$blob([[:space:]]|\$)"; then
used[$blob]=1
continue
fi
printf '%s\n' "$out" >&2
# The sha is the first thing anyone needs: it is how you read the blob and
# how you would allow it. scan_blob reports a path, and a path in history
# is ambiguous across commits.
warn " blob $blob -- read it with: git cat-file blob $blob"
rc=1
done < <(
git rev-list --objects --all \
| git cat-file --batch-check='%(objectname) %(objecttype) %(rest)' \
| sed -n 's/^\([0-9a-f]\{7,\}\) blob \(..*\)$/\1 \2/p' \
| sort -u
)
# An entry that matches nothing is either a typo or a leftover from rewritten
# history. Reported, not fatal: a stale allowance grants nothing, but a list
# nobody prunes is a list nobody reads.
while read -r sha _; do
case "$sha" in ''|'#'*) continue ;; esac
[[ -n "${used[$sha]:-}" ]] || warn "note: allowance matches no blob in history: $sha"
done < <(allowed)
(( rc )) && refuse publish
exit "$rc"
SCANALL
hooks/scan-history --self-test
cd "$ADM_ROOT"
test -x hooks/scan-history || { echo "hooks/scan-history is not executable"; exit 1; }
hooks/scan-history --self-test
# The point of the thing: it must pass over this repo's own history. If this
# fails, something in a past commit is in the clear and no bundle may ship.
hooks/scan-history || { echo "history contains a secret in the clear"; exit 1; }
Arm them
cd "$ADM_ROOT"
git config core.hooksPath hooks
git config --get core.hooksPath
cd "$ADM_ROOT"
test "$(git config core.hooksPath)" = "hooks" \
|| { echo "core.hooksPath is '$(git config core.hooksPath)', expected 'hooks'"; exit 1; }
# Git tracks the executable bit; losing it would hand a clone a dead hook.
for spec in hooks/pre-commit:100755 hooks/pre-push:100755 hooks/secret-scan.sh:100644; do
f="${spec%%:*}"; want="${spec##*:}"
mode="$(git ls-files -s "$f" | awk '{print $1}')"
case "$mode" in
"$want") : ;;
"") echo "$f is not tracked yet -- commit it so clones get it"; exit 1 ;;
*) echo "$f is committed as $mode, not $want"; exit 1 ;;
esac
done
echo "hooks armed: pre-commit, pre-push, shared library, all tracked"
The detector cannot contain its own patterns
The first version of this refused to commit itself, and correctly so: its
KEY_PATTERNS spelled the age-key prefix out in full, so the hook's own
source — and the runbook containing it — looked exactly like a leaked key.
The tempting fix is to exempt hooks/ and runbooks/ from the key scan. That
is a loophole: a real key pasted into a runbook is a plausible mistake, and
would then sail through.
The actual fix is to write each pattern so it matches a real key but not its own text, by bracketing one character:
AGE-SECRET-KEY-[1] matches a real age key, not this line
BEGIN OPENSSH PRIVATE [K]EY
BEGIN [A-Z ]*PRIVATE KEY already self-safe: '[' is not in [A-Z ]
The self-test builds its sample with printf for the same reason. This is a
general hazard for any secret scanner kept in the repository it scans, and it
is why the third pattern needed no change while the first two did.
Checking it yourself
--self-test exercises the logic against inline samples, but it does not
prove git calls the hooks. These do, and leave nothing behind. From the repo
root:
# pre-commit
printf 'BOX_NAME=test\nAPI_TOKEN=hunter2\n' > test.env
git add test.env && git commit -m 'should be refused' # must fail
git reset && rm -f test.env
# pre-push: commit past the hook deliberately, then try to push
printf 'AGE-SECRET-KEY-%s\n' 1QQQ > leaked-key.txt
git add leaked-key.txt
git commit --no-verify -m 'bypassed pre-commit'
git push # must fail
git reset --hard HEAD~1 && rm -f leaked-key.txt
The second case is the one worth running: it reproduces the exact situation
pre-push exists for — a secret that got past pre-commit because someone
used --no-verify.
test.env deliberately sits at the repo root rather than in a box directory,
so adm cannot mistake it for a real box while the test is in progress.
Limits
- Neither sees the working tree. Both run at commit or at push time, so a
plaintext value sitting in
<box>.envright now is invisible to them until someone tries to commit it — and the exposure began when it was written, not when it was committed.env-fully-encryptedabove closes that gap, on everyadm verify. --no-verifybypasses both, deliberately. A check that cannot be overridden becomes one people disable permanently; one that can be is a speed bump in the right place.pre-pushis what makes a bypassedpre-commitsurvivable.- Server-side is still unguarded. Both hooks run on this box, so a push from a different clone with hooks unarmed is unchecked. Only a forge-side pre-receive hook closes that, and we do not control the forge's hooks from here.
- Neither can retract history. Anything already committed in the clear stays in the repository — see DECISIONS.md #004, which is why this repo is shareable but not publishable.
Prove a round trip
Configuration asserts the setup; this proves the thing that matters — that a secret written in the repo comes back out intact on this box.
cd "${ADM_BOX_DIR:?ADM_BOX_DIR unset}"
got="$(sops -d --input-type dotenv --output-type dotenv "${BOX_NAME}.env" \
| awk -F= '$1 == "GIT_REMOTE" { print $2 }')"
test -n "$got" || { echo "GIT_REMOTE did not decrypt"; exit 1; }
case "$got" in
ssh://*) : ;;
*) echo "GIT_REMOTE decrypted to something unexpected"; exit 1 ;;
esac
# And adm's own loader must agree, since that is what blocks actually see.
test -n "${GIT_REMOTE:-}" \
|| { echo "adm did not export GIT_REMOTE into this block"; exit 1; }
test "$GIT_REMOTE" = "$got" \
|| { echo "adm exported a different GIT_REMOTE than sops decrypts"; exit 1; }
echo "secret round trip ok: sops and adm agree on GIT_REMOTE"
What this does and does not protect
Does: the repo can be pushed to a shared or public remote without revealing the encrypted values. History is reviewable, because keys stay readable and only values are opaque.
Does not: protect the secrets from anyone with access to this box. The
age key is on it, in plaintext, by design — adm replay has to work
unattended. The deploy key in $HOME/.ssh is plaintext for the same reason.
Encrypting <box>.env changes the repo's exposure, not the box's.
Does not, on its own, make this repo shareable. Encrypting one file is
necessary and nowhere near sufficient — see
DECISIONS.md #004 for the rest of what leaks, notably
hosts/<box>/facts/, which records internal addresses, listening sockets and account
names in plain text.
020 — Off-box remote for this repo
Required — every box runs this.
Give /opt/admin somewhere to live that isn't the box it documents.
Until this runbook ran, the only copy of this repo's history was on rea-dev
itself, which defeats most of the purpose: the rebuild procedure was stored
exclusively on the machine it exists to rebuild.
The remote is a Forgejo instance, prepared in advance. Its URL is
$GIT_REMOTE from <box>.env — an encrypted value, because it names a
private forge and account and this repo is meant to be shareable. So this
runbook depends on 015-secrets.md having made decryption
work; without it every block here fails fast with a clear message rather than
operating on an empty URL.
Host and port are derived from that URL rather than stored separately, so there is one source of truth and nothing to drift.
Why a per-repo deploy key, owned by admin
The key lives at $HOME/.ssh/id_ed25519_admin_repo — named for its one
job rather than called id_ed25519, because it is a deploy key scoped to
a single repository, not admin's general-purpose identity. If this box ever
needs to reach a second repo on the same forge, it gets its own key and its
own Host alias; nothing here has to be untangled first.
Root holds no key and no SSH config, and must not acquire either — see
010-admin-user.md. Outbound SSH from this box is
admin's business.
The key has no passphrase. Pushes have to work unattended, and a passphrase stored anywhere the box can read it is not a passphrase. The blast radius is bounded by what the key can reach: write access to this one repository. That is a real exposure — someone with the key can rewrite the box's own documentation — and it is accepted deliberately rather than by omission.
The comment must be a single token
A public key line is <type> <base64> <comment>, and Forgejo's deploy-key
form splits it on whitespace. A descriptive, multi-word comment produces an
eight-field line, which sends its parser down a path where it tries to
base64-decode part of the comment and fails:
Cannot verify your SSH key: extractTypeFromBase64Key:
invalid key format: illegal base64 data at input byte 6
The first version of this block used
a multi-word description naming the repo and path — informative in
ssh-keygen -l output, and rejected by the only system that had to accept it.
Keep the comment to one token; the runbook is where the explanation belongs.
The comment is converged, not just set at creation: if an existing key
carries the wrong one, the block repairs it in place rather than leaving a
key whose public line cannot be pasted anywhere. ssh-keygen -c rewrites the
comment without touching the keypair, so the fingerprint is stable and the
forge needs no update.
key="$HOME/.ssh/id_ed25519_admin_repo"
comment="${ADMIN_USER:-admin}@${BOX_NAME}"
install -d -m 0700 "$HOME/.ssh"
if [[ ! -f "$key" ]]; then
ssh-keygen -t ed25519 -N "" -f "$key" -C "$comment"
fresh=1
fi
current="$(awk '{ $1=""; $2=""; sub(/^ +/, ""); print }' "$key.pub")"
if [[ "$current" != "$comment" ]]; then
echo "repairing key comment: '$current' -> '$comment'"
ssh-keygen -c -P "" -C "$comment" -f "$key" >/dev/null
fi
chmod 0600 "$key"
chmod 0644 "$key.pub"
ssh-keygen -lf "$key.pub"
# Only when it was just generated. On every other run the key has long since
# been authorised and printing it again would be noise; this is the one
# moment someone has to act on it.
if [[ "${fresh:-}" == 1 ]]; then
echo
echo "This is a new key, so it is not authorised on the forge yet."
echo "The forge-authorized check below prints it with the URL to add it to."
fi
key="$HOME/.ssh/id_ed25519_admin_repo"
test "$(stat -c '%a %U:%G' "$key")" = "600 $ADMIN_USER:$ADMIN_USER"
test "$(stat -c '%a' "$key.pub")" = "644"
# Passphrase-less by design: an empty passphrase must open it non-interactively.
ssh-keygen -y -P "" -f "$key" >/dev/null \
|| { echo "key is passphrase-protected; unattended push will hang"; exit 1; }
# Exactly three fields, or the forge's deploy-key form rejects the paste.
nf="$(awk '{print NF}' "$key.pub")"
test "$nf" -eq 3 \
|| { echo "public key line has $nf fields, expected 3 -- comment must be one token"; exit 1; }
ssh-keygen -lf "$key.pub"
Pin the forge's host key
Pinned as $FORGE_HOST_FINGERPRINT in <box>.env, captured from this box on
2026-10-18. That value is deliberately not encrypted: a public host key
fingerprint is not a secret, and is useless without knowing the host — which
is. Worth knowing before you go looking for one: this forge offers only an
RSA host key, no ed25519.
This fingerprint was obtained by ssh-keyscan, which is trust-on-first-use:
it proves only that something answering on that port presented this key at
that moment. Because the same person administers the forge, it should be
cross-checked out of band against the server's own
/etc/ssh/ssh_host_rsa_key.pub — until then the pin records continuity, not
authenticity.
Pinning it in the runbook rather than accepting whatever turns up means a rebuild, or a replay after a MITM, fails loudly instead of silently trusting a new key.
# Everything host-specific comes from <box>.env; the remote is an encrypted
# value, so 015 must have run.
url="${GIT_REMOTE:?GIT_REMOTE unset -- run 015-secrets.md so secrets decrypt}"
hp="${url#ssh://}"; hp="${hp#*@}"; hp="${hp%%/*}"
host="${hp%%:*}"; port="${hp##*:}"
[[ "$port" == "$host" ]] && port=22
kh="$HOME/.ssh/known_hosts"
expected="${FORGE_HOST_FINGERPRINT:?FORGE_HOST_FINGERPRINT unset in <box>.env}"
install -d -m 0700 "$HOME/.ssh"
touch "$kh"; chmod 0600 "$kh"
if ! ssh-keygen -F "[$host]:$port" -f "$kh" >/dev/null; then
tmp="$(mktemp)"
ssh-keyscan -p "$port" "$host" > "$tmp" 2>/dev/null
test -s "$tmp" || { echo "keyscan returned nothing for $host:$port"; rm -f "$tmp"; exit 1; }
if ! ssh-keygen -lf "$tmp" | awk '{print $2}' | grep -qx "$expected"; then
echo "HOST KEY MISMATCH -- refusing to trust $host:$port"
echo "expected: $expected"
echo "got:"; ssh-keygen -lf "$tmp" | sed 's/^/ /'
rm -f "$tmp"; exit 1
fi
cat "$tmp" >> "$kh"; rm -f "$tmp"
fi
ssh-keygen -F "[$host]:$port" -f "$kh" -l
# Everything host-specific comes from <box>.env; the remote is an encrypted
# value, so 015 must have run.
url="${GIT_REMOTE:?GIT_REMOTE unset -- run 015-secrets.md so secrets decrypt}"
hp="${url#ssh://}"; hp="${hp#*@}"; hp="${hp%%/*}"
host="${hp%%:*}"; port="${hp##*:}"
[[ "$port" == "$host" ]] && port=22
kh="$HOME/.ssh/known_hosts"
expected="${FORGE_HOST_FINGERPRINT:?FORGE_HOST_FINGERPRINT unset in <box>.env}"
ssh-keygen -F "[$host]:$port" -f "$kh" -l | awk '{print $3}' \
| grep -qx "$expected" \
|| { echo "pinned host key for the forge is missing or changed"; exit 1; }
echo "forge host key pinned and unchanged"
SSH config for admin
The remote URL already carries the user and port, so this stanza's User
and Port are belt and braces. Its real job is IdentityFile plus
IdentitiesOnly, so git presents this one deploy key and nothing else.
Written whole from a heredoc, never appended — per AGENTS.md, an appending block duplicates its own output on the second run.
# Everything host-specific comes from <box>.env; the remote is an encrypted
# value, so 015 must have run.
url="${GIT_REMOTE:?GIT_REMOTE unset -- run 015-secrets.md so secrets decrypt}"
hp="${url#ssh://}"; hp="${hp#*@}"; hp="${hp%%/*}"
host="${hp%%:*}"; port="${hp##*:}"
[[ "$port" == "$host" ]] && port=22
user="${url#ssh://}"; user="${user%%@*}"
install -d -m 0700 "$HOME/.ssh"
install -m 0600 /dev/stdin "$HOME/.ssh/config" <<EOF
# Managed by $ADM_ROOT/runbooks/020-admin-repo-remote.md
# Hand edits are overwritten on the next \`adm replay\`.
Host $host
Port $port
User $user
IdentityFile ~/.ssh/id_ed25519_admin_repo
IdentitiesOnly yes
EOF
cat "$HOME/.ssh/config"
# Everything host-specific comes from <box>.env; the remote is an encrypted
# value, so 015 must have run.
url="${GIT_REMOTE:?GIT_REMOTE unset -- run 015-secrets.md so secrets decrypt}"
hp="${url#ssh://}"; hp="${hp#*@}"; hp="${hp%%/*}"
host="${hp%%:*}"; port="${hp##*:}"
[[ "$port" == "$host" ]] && port=22
test "$(stat -c '%a %U:%G' "$HOME/.ssh/config")" = "600 $ADMIN_USER:$ADMIN_USER"
g="$(ssh -G "$host" 2>/dev/null)"
grep -qx "port $port" <<<"$g" || { echo "port $port not applied"; exit 1; }
grep -q "^identityfile.*id_ed25519_admin_repo" <<<"$g" \
|| { echo "deploy key not selected for the forge"; exit 1; }
echo "ssh config resolves correctly for the forge"
The git remote
cd "$ADM_ROOT"
url="${GIT_REMOTE:?GIT_REMOTE unset -- run 015-secrets.md so secrets decrypt}"
if git remote get-url origin >/dev/null 2>&1; then
git remote set-url origin "$url"
else
git remote add origin "$url"
fi
git remote -v
cd "$ADM_ROOT"
test "$(git remote get-url origin)" = "${GIT_REMOTE:?GIT_REMOTE unset}" \
|| { echo "origin does not match GIT_REMOTE from <box>.env"; exit 1; }
echo "origin matches GIT_REMOTE"
The manual step: authorise the deploy key
This is the one step in this repo that a human must perform by hand, and it cannot be automated away without handing the box credentials to the forge's API — a strictly larger secret than the key it would install.
Add the public key printed by admin-deploy-key as a deploy key on the
forge. For Forgejo and Gitea the page is https://<host>/<org>/<repo>/settings/keys;
the forge-authorized verify below prints the exact URL for this box, derived
from $GIT_REMOTE, so the address is not written into a shareable file.
Enable write access. Forgejo and Gitea create deploy keys read-only by
default, and a read-only key fails at git push with a message about the
repository not being found rather than anything about permissions — which
wastes a surprising amount of time.
The verify below fails until that is done. That is correct behaviour, not a
defect: like reboot-needed in 000, it is an honest report of something
untrue.
cd "$ADM_ROOT"
# Everything host-specific comes from <box>.env; the remote is an encrypted
# value, so 015 must have run.
url="${GIT_REMOTE:?GIT_REMOTE unset -- run 015-secrets.md so secrets decrypt}"
hp="${url#ssh://}"; hp="${hp#*@}"; hp="${hp%%/*}"
host="${hp%%:*}"; port="${hp##*:}"
[[ "$port" == "$host" ]] && port=22
path="${url#ssh://*@"$hp"/}"; path="${path%.git}"
GIT_SSH_COMMAND='ssh -o BatchMode=yes -o ConnectTimeout=10' \
git ls-remote origin >/dev/null 2>&1 \
|| { echo "deploy key not authorised yet (or lacks access)."
echo
echo "Add this public key at:"
echo " https://$host/$path/settings/keys"
echo "giving it WRITE access. The title can be anything; the key's"
echo "own comment names the box, which most forges offer as the"
echo "default."
echo
cat "$HOME/.ssh/id_ed25519_admin_repo.pub"
echo
echo "Printed in full rather than named by path, because it is a"
echo "public key: nothing is risked by it appearing here, and the"
echo "alternative is whoever reads this going to fetch it."
exit 1; }
echo "forge reachable and key authorised"
The forge API token
The ssh key above pushes branches. Filing an issue, opening a pull request
and merging it go over the forge's HTTP API instead, which wants a token —
and adm issue, adm land and adm upstream all read it from
<box>.env as FORGE_TOKEN.
No runbook mentioned it until now, because bin/adm reads it rather than a
block does. That had a consequence beyond the documentation gap: adm adopt
and adm env-fill both derive the list of keys a new box needs from the
${KEY:?} assertions in these runbooks, so neither knew the most secret
value in the repository existed. A box bootstrapped from scratch got as far
as 015 with nothing telling it to collect a token, and found out at the
first adm land.
Create one in the forge under Settings → Applications → Generate New
Token with the write:issue scope, then add it with adm env-fill — never
by editing the file, for the reasons 015 gives.
cd "$ADM_ROOT"
: "${FORGE_TOKEN:?not set in $BOX_NAME.env -- create one in the forge under Settings -> Applications (scope write:issue) and add it with: adm env-fill}"
# Checked through adm rather than curl. adm derives the API base and the
# owner/repo slug from GIT_REMOTE, and doing that again here would be a
# second derivation free to disagree with the one that does the work. A dry
# run exercises exactly that path, and prints no token -- which a curl would,
# because the Authorization header would sit in argv where every process on
# the box can read it. 070 makes the same argument about agent arguments.
#
# -b '' rather than no body: with no -b and a non-tty stdin, adm issue reads
# the body from stdin, and a verify block does not have one to give.
adm issue -n -b '' "probe: can adm address the forge API" >/dev/null
echo "FORGE_TOKEN present; adm can address the forge API"
Why this step is not automated
The forge has an API for deploy keys, so 020 could register it and the
manual step would disappear. It does not, deliberately.
Adding a key through POST /repos/{owner}/{repo}/keys needs a token with
repository scope. FORGE_TOKEN is created with write:issue, which is what
adm issue, adm land and adm upstream need, and it is not enough —
measured, not assumed:
GET /repos/<slug>/keys -> HTTP 403 Forbidden
The finest scope that can add a key also permits editing the repository's
settings, cutting releases and deleting branches. So automating one manual
step would mean every box storing a credential that can reshape its
repository — and since 080 embeds the encrypted env, that
wider token travels inside the published document. A larger prize behind the
same ciphertext, to save one paste.
What is automated instead is the part that costs nothing: the key is printed in full, beside the URL to add it to. An agent can do that much safely, because a public key is public — which is the one thing in this repository an agent may hold and read aloud.
What a rebuild means for this key
A rebuilt box generates a new keypair, so the forge step must be repeated by hand. That is an honest gap in the "replay reproduces the box" claim — one manual action sits in the middle of it.
The alternative is to treat the private key as a managed secret: keep it in the password manager and have this runbook install it rather than generate it, making replay fully unattended at the cost of a long-lived secret stored off-box. Not chosen yet; generating fresh is the safer default and the manual step is cheap at this scale. Revisit if this box is ever rebuilt often enough for it to chafe.
Push
Safe to re-run: an up-to-date branch pushes as a no-op. It fails on genuine divergence, which is a real condition worth stopping for rather than papering over with a force push.
On a rebuild you would clone from this remote rather than push to it — the repo has to exist on the box before it can replay anything, so cloning is the bootstrap. See the rebuild section in README.md.
cd "$ADM_ROOT"
git push -u origin master
git branch -vv --list master
cd "$ADM_ROOT"
# master against origin/master by name. Using HEAD and @{upstream} meant this
# reported "master and its upstream disagree" while standing on a branch that
# simply had no upstream yet -- which says nothing about master.
test "$(git rev-parse master)" = "$(git rev-parse origin/master)" \
|| { echo "master and origin/master disagree"; exit 1; }
echo "master is in sync with origin/master"
Upstream: where the runbooks come from
origin is this machine's private repo. upstream is the public adm
repository every box's generic half comes from, and this repo is a fork of it:
its first commit is upstream's first commit, same object id. That is what
makes pulling fixes an ordinary merge instead of an argument.
That first commit's id is written down in the check below, the way
FORGE_HOST_FINGERPRINT is written down in 015: a fact about something
remote, recorded so it can be checked without asking the remote. The check
needs it because a box rebuilt from the published document has no history
and begins with git init -- a fresh, unrelated root. Asking upstream would
answer the question, but only from a box that can reach the network, and the
boxes most likely to have the wrong root are exactly the ones rebuilt
offline. Recorded, the answer is available always, so a wrong root is a
failure rather than a skipped check.
cd "$ADM_ROOT"
test -n "${ADM_UPSTREAM:-}" || ADM_UPSTREAM=https://github.com/EnigmaCurry/adm.git
git remote get-url upstream >/dev/null 2>&1 \
|| git remote add upstream "$ADM_UPSTREAM"
git remote set-url upstream "$ADM_UPSTREAM"
# Fetch over https, push over ssh. Pulling from upstream is routine and must
# not depend on an agent being forwarded; pushing is occasional, needs a key
# the public repo has authorised, and is the direction worth making awkward.
test -n "${ADM_UPSTREAM_PUSH:-}" \
|| ADM_UPSTREAM_PUSH="$(printf '%s' "$ADM_UPSTREAM" \
| sed -E 's|^https://([^/]+)/|git@\1:|')"
git remote set-url --push upstream "$ADM_UPSTREAM_PUSH"
git remote get-url upstream
git remote get-url --push upstream
cd "$ADM_ROOT"
url="$(git remote get-url upstream 2>/dev/null)" \
|| { echo "no upstream remote; run this runbook"; exit 1; }
case "$url" in
https://*) : ;;
*) echo "upstream should be fetched over https: it is public, needs no key,"
echo "and an ssh url would make a routine fetch depend on an agent"; exit 1 ;;
esac
# A fork shares upstream's root commit. Without that there is no merge base,
# and every per-box file arrives as a modify/delete conflict on first merge.
#
# Compared against the recorded id, not against a fetch. This check used to
# fetch and skip itself when the network was down, which passed the one box
# that most needed failing: a box rebuilt from the published document has an
# unrelated root by construction, and an offline rebuild is how it gets there.
# Declared here and nowhere else. Override it together with $ADM_UPSTREAM
# when forking adm for a fleet of your own: a root commit from one upstream
# means nothing to another.
test -n "${ADM_UPSTREAM_ROOT:-}" \
|| ADM_UPSTREAM_ROOT=5f06bf3ecc68dca60df75b27a247521ed6e38ce4
root_local="$(git rev-list --max-parents=0 HEAD | tail -1)"
if test "$root_local" != "$ADM_UPSTREAM_ROOT"; then
echo "this repo does not share upstream's root commit:"
echo " here: $root_local"
echo " upstream: $ADM_UPSTREAM_ROOT"
echo "merges would need --allow-unrelated-histories and would conflict"
echo "on every per-box file. See DECISIONS 007."
echo
echo "A repo rebuilt from the published document starts this way, because"
echo "git init makes its own root. To re-fork it without losing the work:"
echo
echo " git fetch upstream master"
echo " git reset --soft upstream/master # move HEAD, keep the tree"
echo " git commit -m 'adopt this box onto upstream'"
echo
echo "The soft reset leaves the index holding this tree while HEAD becomes"
echo "upstream's tip, so the commit that follows has upstream as its parent"
echo "and every file exactly as it is now. Not --amend: that would replace"
echo "upstream's own tip commit instead of building on it."
echo
echo "Files upstream tracks that the document does not carry -- hooks/ --"
echo "will show as deleted until 015 writes them again, which it does."
echo "Then push once with --force-with-lease; the root commit changed."
exit 1
fi
# When the network is there, confirm the recorded id is still upstream's. It
# would not be if upstream were rewritten -- which has happened once -- and a
# recorded fact that has gone stale is worse than no fact.
if git fetch -q upstream 2>/dev/null; then
root_up="$(git rev-list --max-parents=0 upstream/master | tail -1)"
test "$root_up" = "$ADM_UPSTREAM_ROOT" \
|| { echo "the recorded root is stale: this runbook says"
echo " $ADM_UPSTREAM_ROOT"
echo "but $(git remote get-url upstream) now roots at"
echo " $root_up"
echo "upstream was rewritten. Update ADM_UPSTREAM_ROOT in this"
echo "runbook, and expect to re-fork every box."; exit 1; }
fi
push="$(git remote get-url --push upstream 2>/dev/null)"
case "$push" in
git@*|ssh://*) : ;;
*) echo "upstream pushes over '$push'; it should be ssh, so a push uses a"
echo "key rather than prompting for a password on a headless box"; exit 1 ;;
esac
echo "upstream $url (push $push), sharing root ${root_local:0:12}"
Taking a fix from upstream
git fetch upstream
git log --oneline master..upstream/master # what is new up there
git merge upstream/master
adm verify # the acceptance test
The last line is the point. Merging changes the runbooks; adm verify says
whether this machine still matches them, and each failure names the adm run
now owed. A merge that leaves verify green changed nothing about this box.
Conflicts appear only where this machine edited a generic file, which is a signal rather than a nuisance: it means a fix was made here that belongs upstream.
Sending a fix upstream
adm upstream -n # what would be published
adm upstream # branch it, push it, print the pull request link
adm upstream --pull # after it is merged: fetch, check, merge, push
Cherry-picking commits was the obvious design and does not work here. adm run rewrites the box journal on every invocation, so it lands in whatever
commit follows: of the 117 commits in this repo's history before the split,
78 touched both halves, every one of them because of that journal. A
workflow that can only carry generic-only commits would carry an eighth of
the work.
So adm upstream publishes the diff restricted to generic paths, which
does not care how local commits are arranged. It takes the delta between
upstream/master and master with hosts/ and operator.pub excluded,
applies it to a branch cut from upstream/master, and pushes that. The
exclusion is a git pathspec rather than a filter afterwards — a filter
applied afterwards is what published one box's facts the first time this was
attempted, see DECISIONS 007.
It refuses to run with uncommitted changes to generic files, because the
delta comes from committed state and a publish that silently omits what you
just wrote is worse than no publish. It builds the branch in a temporary
worktree, so the live checkout never has its files swapped for another
branch's while the machine depends on them. And it never pushes master: the
branch is the deliverable, a pull request is the merge, and upstream enforces
that too.
What the pull request says
Publishing a diff loses something a cherry-pick would have kept: the commit
messages, which on this side are the documentation. Upstream recorded only
sync the generic half from a fork at 439b139 and a merge commit naming the
same sha — a thread leading back into a private repository, useful to one
person.
A forge fills the pull request form from the single commit on a branch, and
the sync branch has exactly one commit, so a real message there is the whole
fix: title and body arrive filled in, with nothing opening the request but a
person. So adm upstream builds that message from the fork commits whose
changes it is publishing. One commit contributes its message verbatim — it
was written for this, and copying beats summarising. Several get a subject
naming the places touched and a body quoting each in turn.
Which commits those are depends on knowing where the last sync stopped, and
the only record both sides can see is upstream's own history, so the sha is
parsed back out of it. That makes the trailer a format rather than a
sentence, and adm upstream checks that it can read back the sha it just
wrote: a message that cannot be parsed would break the sync after it, which
is a failure that surfaces a week later as a commit list reaching back to the
first commit in the repo. That is not hypothetical — it is what the first run
of this did, because every sync already upstream was written in the older
wording and the new parser did not know it.
adm upstream -n prints the message it would use, so the description can be
read before anything is pushed. -m still overrides the prose entirely —
but not the trailer, which is appended to a hand-written message that does
not already carry this fork's sha. That is machine data rather than prose,
and requiring an operator to remember a format is how it goes missing: the
one sync published with -m carried no trailer, and the sync after it had no
boundary and re-listed two changes already upstream.
Which puts a requirement on the fork's commit messages that they did not have
before: each one is a public pull request description, read by someone with no
issue to click through to and no access to the repository it came from. A
message written against the context of an issue reads as a defence of a fix
for an unstated problem. State the problem first and the implementation
second — 070 says this to the agent as well, since
it is the one writing most of them.
cd "$ADM_ROOT"
# The parse must ignore the format when it appears inside quoted prose. That
# is not a hypothetical: _sync_message quotes fork commit messages verbatim,
# and the commit that introduced it quotes an older sync line in its own
# explanation. A substring search found that sha instead of the trailer --
# caught in _sync_message, which refuses, but the same bug in _last_sync_sha
# chooses a boundary from a previous era and publishes a commit list reaching
# past it, with nothing to indicate anything went wrong.
PYTHONDONTWRITEBYTECODE=1 python3 - <<'PY'
import importlib.machinery as M, importlib.util as U, sys
sys.argv = ["adm"]
loader = M.SourceFileLoader("a", "bin/adm")
mod = U.module_from_spec(U.spec_from_loader("a", loader))
loader.exec_module(mod)
quoted = """sync 2 generic changes: 020
* 020: say what the pull request says
Upstream recorded only "sync the generic half from a fork at 439b139"
and a merge commit naming the same sha.
Synced from the fork's generic half at abc1234."""
got = mod._parse_sync_sha(quoted)
assert got == "abc1234", "quoted sha won over the trailer: %r" % got
# Every sync already upstream is written this way; it has to stay readable.
assert mod._parse_sync_sha(
"sync the generic half from a fork at 439b139") == "439b139"
assert mod._parse_sync_sha("no sync here at all") == ""
# A message written by hand must end up carrying the trailer too. -m replaces
# the generated message entirely, and the check for this used to live inside
# the generator, so it only ever examined messages that could not fail. The
# commit-identity sync went out with -m, carried no trailer, and the sync
# after it re-listed two changes that were already upstream.
got = mod._with_sync_trailer("a subject and nothing else", "abc1234")
assert mod._parse_sync_sha(got) == "abc1234", got
# A trailer copied from an older message must not win: ours is appended after
# it and the parse takes the last match.
stale = "subject\n\nSynced from the fork's generic half at 0000000."
got = mod._with_sync_trailer(stale, "abc1234")
assert mod._parse_sync_sha(got) == "abc1234", got
# And one that already says the right thing is left alone.
assert mod._with_sync_trailer(got, "abc1234") == got
print("boundary: trailer beats quoted prose, old format read, -m always carries one")
PY
Bringing it back
After the pull request is merged, pull it back so the delta returns to zero:
adm upstream --pull
This used to be documented as git fetch upstream && git merge upstream/master, which is two of the four things it needs to be and leaves
adm verify red. initial-push compares master to origin/master by
name, and a merge that has not been pushed is a disagreement — so following
the runbook exactly produced a failure about the documentation rather than
about the box, which is the worst kind to hand someone.
The push is the small half. The half that matters is the check in the middle, and it is the reason this is a command rather than a longer instruction.
A merge faithfully replays a deletion. .sops.yaml reached the public
repo once, carrying one box's rule and recipients. It was deleted there to
fix that, and pulling the fix back deleted this box's own .sops.yaml and
broke every sops write on it. Nothing was wrong with the merge; it did
exactly what it was told.
So --pull refuses when upstream deletes a path that adm upstream never
publishes. The rule is derived from that exclusion list rather than written
down again, because the two questions are one question: a file upstream never
received is a file upstream has no business deleting. A deletion of anything
generic is upstream's to make, and passes with a note.
It compares against the merge base, not against the tracking ref as it stood before its own fetch. That was the first implementation, and it only worked when nothing had fetched already — a condition no operator maintains. The merge base is where the two histories last agreed, so the range is what upstream has done since, no matter who fetched when. The test that caught this did so only because a second check, run after the merge, refused to push a tree that had lost something; that check is still there, and now it should never be the one that fires.
It also refuses to run off master, or with a dirty tree, because a merge
that stops halfway leaves neither state.
cd "$ADM_ROOT"
# The classification, not the plumbing: given a path, is it one upstream
# never receives? Everything else about --pull is git doing what git does.
PYTHONDONTWRITEBYTECODE=1 python3 - <<'PY'
import importlib.machinery as M, importlib.util as U, sys
sys.argv = ["adm"]
loader = M.SourceFileLoader("a", "bin/adm")
mod = U.module_from_spec(U.spec_from_loader("a", loader))
loader.exec_module(mod)
# The file whose deletion broke the box, and the shapes around it.
# A box name that is nobody's. Writing this box's name here put it in a
# generic runbook, which 080's check refused -- correctly, and immediately.
owned = [".sops.yaml", "operator.pub",
"hosts/.gitignore", "hosts/somebox/somebox.env"]
shared = ["runbooks/015-secrets.md", "bin/adm", "README.md",
"skills/adm.md", "hooks/secret-scan.sh",
# Named like an owned path but not one. A rule written as a
# substring test rather than a prefix would call these private.
"docs/hosts-and-boxes.md", "notes/.sops.yaml.md"]
for p in owned:
assert mod._never_published(p), "%s should be ours alone" % p
for p in shared:
assert not mod._never_published(p), "%s is generic" % p
# And that the rule is the exclusion list, not a copy of it: every exclusion
# must classify as owned, or the two have drifted apart.
for spec in mod.UPSTREAM_EXCLUDE:
pat = spec[len(":(exclude)"):]
sample = pat + "x/y" if pat.endswith("/") else pat
assert mod._never_published(sample), "%s not covered" % sample
print("pull-back guards %d excluded path(s), and nothing generic"
% len(mod.UPSTREAM_EXCLUDE))
PY
Without pulling back, the next adm upstream would offer the same changes
again.
030 — Disable cloud-init
Required — every box runs this.
Stop cloud-init from running at boot, now that the box is provisioned and long-lived.
Why
cloud-init exists to turn a generic image into a specific machine on first boot. That job is finished and will not be done again — this box is managed by the runbooks in this repo. Leaving it enabled has two costs.
It re-runs modules on every boot and reverts state it considers its own.
The live example is the hostname. /etc/cloud/cloud.cfg has
preserve_hostname: false with both set_hostname and update_hostname in
the module list, and /var/lib/cloud/data/previous-hostname records arch —
cloud-init set that name and tracks it. Renaming the box without disabling
cloud-init invites it to be renamed back at the next reboot.
Worse, the seed's meta-data contains only an instance-id and no
local-hostname, so what cloud-init would actually do on a renamed box is
ambiguous rather than merely unwanted. Disabling it replaces a question with
a fact.
It is five boot-time services and a systemd generator parsing an attached seed device, for no remaining benefit.
Why this runbook is numbered after 010 and 020
On a rebuild, cloud-init must be allowed to do its job first: it is what
installs root's authorized_keys, which 010 copies to
admin, and what writes the persistent network configuration this box boots
with. Disable it before that and a fresh box comes up with no way in.
So the order is deliberate — provision, adopt, then disable.
One ordering wart, stated plainly: the hostname is set in
000, which replays before this runbook.
On a fresh box that means a reboot occurring between the two would let
cloud-init revert the name. Both blocks are idempotent and 000's hostname
verify catches the reversion, so a replay repairs it — but if you are
rebuilding, run this runbook before rebooting.
Preconditions: what would make disabling unsafe
cloud-init renders network configuration from the seed. If it wrote that config somewhere ephemeral, disabling it would strand the box at next boot with no network — and this box is administered as though no hypervisor console exists, so that is unrecoverable.
Checked here rather than assumed. On this box it writes
/etc/systemd/network/10-cloud-init-eth0.network, which is persistent, and
/run/systemd/network/ is empty. Keep this as a standing assertion: it is
cheap, and it is the difference between a safe change and a stranded host.
shopt -s nullglob
persistent=(/etc/systemd/network/*.network /etc/systemd/network/*.netdev)
if (( ${#persistent[@]} == 0 )); then
echo "FAIL: no persistent network config in /etc/systemd/network/"
echo "Disabling cloud-init could leave this box with no network at boot."
sudo ls -la /run/systemd/network/ 2>/dev/null || true
exit 1
fi
printf 'persistent network config: %s\n' "${persistent[@]}"
networkctl --no-pager status 2>/dev/null | grep -E "State:|Address:|Gateway:" || true
Disable it
Two mechanisms, because they cover different things.
The flag file /etc/cloud/cloud-init.disabled is the supported switch.
cloud-init is not enabled by ordinary unit symlinks here — cloud-init status
reports boot_status_code: enabled-by-generator, meaning
/usr/lib/systemd/system-generators/cloud-init-generator decides at every
boot whether to pull the units in. That generator checks for this file, so
masking units alone would leave the generator still making the decision.
Masking the units then makes the intent visible to anyone reading
systemctl list-unit-files, and blocks a manual or dependency-driven start.
Belt and braces, in the order that matters: flag first, masks second.
# Refuse to proceed if the precondition above does not hold.
shopt -s nullglob
persistent=(/etc/systemd/network/*.network /etc/systemd/network/*.netdev)
(( ${#persistent[@]} > 0 )) || {
echo "refusing: no persistent network config in /etc/systemd/network/"; exit 1; }
sudo install -d -m 0755 /etc/cloud
sudo install -m 0644 /dev/stdin /etc/cloud/cloud-init.disabled <<'EOF'
Disabled deliberately. See /opt/admin/runbooks/030-disable-cloud-init.md
cloud-init's provisioning job is complete and this box is managed by the
runbooks in /opt/admin. Removing this file re-enables cloud-init at the next
boot, which may revert the hostname and rewrite network configuration from
the seed device.
EOF
sudo systemctl mask --quiet \
cloud-init-main.service \
cloud-init-local.service \
cloud-init-network.service \
cloud-config.service \
cloud-final.service \
cloud-init.target
sudo cloud-init status --long 2>&1 | head -4 || true
sudo test -f /etc/cloud/cloud-init.disabled \
|| { echo "the cloud-init.disabled flag is missing"; exit 1; }
for u in cloud-init-main cloud-init-local cloud-init-network \
cloud-config cloud-final; do
state="$(systemctl is-enabled "$u.service" 2>&1 || true)"
test "$state" = "masked" || { echo "$u.service is '$state', expected masked"; exit 1; }
done
test "$(systemctl is-enabled cloud-init.target 2>&1 || true)" = "masked" \
|| { echo "cloud-init.target is not masked"; exit 1; }
# The authority on whether it will run again.
status="$(sudo cloud-init status 2>&1 || true)"
grep -qi "disabled" <<<"$status" \
|| { echo "cloud-init still reports: $status"; exit 1; }
echo "cloud-init disabled by flag, 6 units masked, status: $status"
What was deliberately not done
The package is still installed. Disabling is reversible by deleting one
file; pacman -Rns cloud-init is the stronger version and remains available
if you want the surface gone entirely. Keeping it costs a few megabytes and
nothing at boot, and keeps a rebuild's first boot working without special
handling.
/var/lib/cloud is left in place. It is the record of what was
provisioned — datasource, instance id, the hostname cloud-init set — and
AS-FOUND.md cites it. Deleting it would destroy evidence
to reclaim nothing.
The cidata seed device is still attached. /dev/sr0 is a 4 MiB
iso9660 image labelled cidata holding meta-data, network-config,
user-data and vendor-data. Detaching it is a hypervisor-side action and
therefore outside what any runbook here can do. Harmless now that nothing
reads it.
Confirming it stuck
The honest test is a reboot: set the hostname, reboot, and check the name survived. Until that has happened, this runbook has demonstrated that cloud-init reports itself disabled, not that it stays out of the way.
# Cross-check: the running hostname matches <box>.env, and cloud-init is not
# positioned to change it. Meaningful after a reboot; cheap before one.
test "$(hostnamectl --static)" = "$BOX_NAME" \
|| { echo "static hostname '$(hostnamectl --static)' != BOX_NAME '$BOX_NAME'"; exit 1; }
sudo test -f /etc/cloud/cloud-init.disabled \
|| { echo "cloud-init re-enabled; the hostname is no longer safe"; exit 1; }
echo "hostname $BOX_NAME, cloud-init disabled; boot-safe"
040 — btrfs snapshots
Required — every box runs this.
Make routine mistakes cheap to undo: a snapshot before every package transaction, a timeline of scheduled snapshots, and file-level restore from any of them.
This is the capability the box was already capable of and wasn't using. The
root filesystem is btrfs with compress-force=zstd:3, so snapshots cost
almost nothing — they are copy-on-write references, not copies.
What this gives you, precisely
- Before/after snapshots around every
pacmantransaction, viasnap-pac. A bad upgrade becomes a diff you can read and a file you can copy back. - Scheduled timeline snapshots with automatic retention, via snapper's systemd timers.
- File-level restore. Mount-free: snapshots appear as ordinary read-only
directories under
/.snapshots/<n>/snapshot/, so recovering a deleted file iscp.
What this does NOT give you — read this before relying on it
These are not backups, and are not meant to be. Every snapshot lives on the same partition, in the same btrfs filesystem, on the same virtual disk. A failed disk or corrupted btrfs metadata takes the snapshots with it. They protect against you, not against hardware.
That is a deliberate division of labour, not a gap. Backups are taken at the hypervisor layer, covering the whole VM: the entire disk including the ESP and the partition table, stored off-box. The two tiers are complementary and cover different failures:
| tier | covers | does not cover |
|---|---|---|
| hypervisor | whole disk, ESP, partition table, host loss, off-box | fine-grained or instant recovery; nothing visible from inside the guest |
| btrfs snapshots (here) | instant file-level undo, pre-pacman state, in-guest |
disk failure, guest loss, anything outside the root filesystem |
So no off-box btrfs send is configured, and none is wanted — replicating
snapshots off-box would duplicate the hypervisor's job less well. btrbk
remains the tool if that ever changes.
Whole-system rollback is not set up, and cannot be cleanly, in this
layout. Root is mounted from the btrfs top-level subvolume (FS_TREE,
subvolid=5) rather than from an @ subvolume:
/dev/sda3 btrfs rw,...,subvolid=5,subvol=/
Name: <FS_TREE> default subvolume: ID 5 (FS_TREE)
The usual snapper rollback works by making a snapshot the default subvolume so the next boot lands in it. That idiom assumes root is an ordinary subvolume that can be swapped out. When root is the top level, there is nothing to swap — the snapshots live inside the very subvolume you would be replacing.
So file-level restore works fine, and "roll the entire system back to 09:00"
does not. Fixing it means moving root into an @ subvolume — a separate and
considerably larger change.
It is probably not worth doing. Whole-VM rollback already exists at the hypervisor layer, and it is strictly more thorough than the btrfs version: it reverts the disk, not just one filesystem. Restructuring the subvolume layout to obtain a weaker version of a capability we already have would be effort spent for a worse result. Recorded as a known limitation, deliberately not a roadmap item.
/efi is not covered. It is a separate vfat partition on an automount.
/boot is covered, because it lives inside the root filesystem — so a
snapshot holds a matching kernel, initramfs and GRUB config, which is what you
want when inspecting an older system state.
The swap subvolume is excluded automatically. btrfs snapshots are not
recursive, and /swap is its own subvolume, so the active 512 M swapfile is
never captured. This is necessary, not incidental: btrfs cannot snapshot a
subvolume containing an active swapfile.
Why this is numbered after 010
Snapshots are the kind of thing you want early — ideally before making any
other change, so there is something to roll back to. It cannot go earlier,
because the retention policy below sets ALLOW_USERS="$ADMIN_USER" with
SYNC_ACL=yes, and snapper applies that as a POSIX ACL. setfacl refuses an
unknown user outright:
setfacl: Option -m: Invalid argument near character 3
So this runbook depends on 010-admin-user.md having
created the account, and filename order is replay order — a runbook may depend
only on what is numbered below it. It was originally numbered 005, which
passed here purely because admin already existed, and would have failed on a
genuine fresh-box replay.
Preconditions: root must be btrfs, and the admin user must exist
Everything here is btrfs-specific, so assert it rather than assume it. On a box whose root is ext4 this runbook must refuse loudly instead of half-working — see the conversion discussion in DECISIONS.md #002.
fstype="$(findmnt -no FSTYPE /)"
test "$fstype" = "btrfs" || {
echo "root filesystem is '$fstype', not btrfs -- this runbook does not apply"
echo "see DECISIONS.md #002 for the ext4 -> btrfs conversion options"
exit 1; }
id -u "${ADMIN_USER:?ADMIN_USER unset -- is <box>.env present?}" >/dev/null 2>&1 || {
echo "user '$ADMIN_USER' does not exist -- run 010-admin-user.md first"
echo "SYNC_ACL cannot grant ACLs to an unknown user"
exit 1; }
echo "root is btrfs ($(findmnt -no SOURCE /)), compression: $(findmnt -no OPTIONS / | tr ',' '\n' | grep -E '^compress' || echo none)"
Packages
| package | why |
|---|---|
snapper |
snapshot management, retention policy, systemd timers |
snap-pac |
pacman hooks that snapshot before and after each transaction |
sudo pacman -S --needed --noconfirm snapper snap-pac
pacman -Qi snapper >/dev/null || { echo "snapper missing"; exit 1; }
pacman -Qi snap-pac >/dev/null || { echo "snap-pac missing"; exit 1; }
echo "snapper $(pacman -Q snapper | awk '{print $2}'), snap-pac $(pacman -Q snap-pac | awk '{print $2}')"
The root snapper config
snapper create-config fails if the config already exists, so it is guarded on
the config file — the authoritative artefact — rather than on parsing
list-configs table output.
Creating the config also creates /.snapshots as a subvolume. That matters:
because it is a subvolume and snapshots are not recursive, snapshots never
contain previous snapshots.
if [[ ! -f /etc/snapper/configs/root ]]; then
sudo snapper -c root create-config /
fi
sudo snapper -c root list-configs
sudo test -f /etc/snapper/configs/root || { echo "no snapper config named 'root'"; exit 1; }
sudo btrfs subvolume show /.snapshots >/dev/null 2>&1 \
|| { echo "/.snapshots is not a btrfs subvolume"; exit 1; }
echo "snapper config 'root' present; /.snapshots is a subvolume"
Retention policy
Left at defaults, snapper accumulates snapshots indefinitely in the
number category — and with snap-pac creating a pair per pacman
transaction, that grows faster than you would guess on a rolling release.
Chosen deliberately, with 250 GiB free and 1.5 GiB in use:
Because the hypervisor owns backup, these snapshots only need to cover the convenience horizon — "undo what I did recently" — not retention of record. Anything older than about a week is the hypervisor's job, and keeping a six-month btrfs timeline would imply a durability guarantee this tier does not actually provide.
| setting | value | why |
|---|---|---|
TIMELINE_LIMIT_HOURLY |
12 | half a day of fine granularity — the common case |
TIMELINE_LIMIT_DAILY |
7 | a week, which is the useful convenience horizon |
TIMELINE_LIMIT_WEEKLY |
2 | a little slack past the week |
TIMELINE_LIMIT_MONTHLY |
0 | month-old recovery is the hypervisor's tier, not this one |
TIMELINE_LIMIT_YEARLY |
0 | likewise, and a year-old snapshot of a rolling-release box is useless |
NUMBER_LIMIT |
50 | snap-pac pairs; ~25 transactions of history |
NUMBER_LIMIT_IMPORTANT |
20 | pairs marked important (kernel, systemd, …) are kept longer |
ALLOW_USERS=admin plus SYNC_ACL=yes lets admin list, diff and read
snapshots without sudo. This grants no new power — admin already has
passwordless sudo — it just makes browsing for a file to restore pleasant
enough that it actually gets used.
btrfs quotas are deliberately left off. Snapper can use qgroups for space-aware cleanup, but qgroups carry a real performance cost on btrfs and a long history of sharp edges. Count-based retention is sufficient here.
sudo snapper -c root set-config \
TIMELINE_CREATE=yes \
TIMELINE_CLEANUP=yes \
TIMELINE_LIMIT_HOURLY=12 \
TIMELINE_LIMIT_DAILY=7 \
TIMELINE_LIMIT_WEEKLY=2 \
TIMELINE_LIMIT_MONTHLY=0 \
TIMELINE_LIMIT_YEARLY=0 \
NUMBER_CLEANUP=yes \
NUMBER_LIMIT=50 \
NUMBER_LIMIT_IMPORTANT=20 \
ALLOW_USERS="$ADMIN_USER" \
SYNC_ACL=yes
sudo snapper -c root get-config | grep -E "TIMELINE_(CREATE|CLEANUP|LIMIT)|NUMBER_(CLEANUP|LIMIT)|ALLOW_USERS|SYNC_ACL"
Asserted against /etc/snapper/configs/root rather than against
snapper get-config. The pretty-printed table separates columns with │
(U+2502 box-drawing), not an ASCII pipe, so the obvious awk -F'|' silently
matches nothing and every lookup returns empty — which is how the first
version of this block "failed" against a config that was in fact correct.
The config file is plain KEY="value" and is the authoritative artefact.
get() { sudo awk -F= -v k="$1" '$1 == k { gsub(/"/, "", $2); print $2 }' \
/etc/snapper/configs/root; }
for pair in TIMELINE_CREATE=yes TIMELINE_CLEANUP=yes TIMELINE_LIMIT_HOURLY=12 \
TIMELINE_LIMIT_DAILY=7 TIMELINE_LIMIT_WEEKLY=2 \
TIMELINE_LIMIT_MONTHLY=0 TIMELINE_LIMIT_YEARLY=0 \
NUMBER_CLEANUP=yes NUMBER_LIMIT=50 NUMBER_LIMIT_IMPORTANT=20 \
SYNC_ACL=yes; do
k="${pair%%=*}"; want="${pair#*=}"; got="$(get "$k")"
test "$got" = "$want" || { echo "$k is '$got', expected '$want'"; exit 1; }
done
test "$(get ALLOW_USERS)" = "$ADMIN_USER" \
|| { echo "ALLOW_USERS is '$(get ALLOW_USERS)', expected '$ADMIN_USER'"; exit 1; }
echo "retention policy applied"
Timers
snapper-timeline.timer creates the scheduled snapshots;
snapper-cleanup.timer applies the retention policy above. Without the
cleanup timer the limits are inert and nothing is ever deleted.
sudo systemctl enable --now snapper-timeline.timer snapper-cleanup.timer
systemctl list-timers --no-pager snapper-\* || true
for t in snapper-timeline.timer snapper-cleanup.timer; do
test "$(systemctl is-enabled "$t")" = "enabled" || { echo "$t not enabled"; exit 1; }
test "$(systemctl is-active "$t")" = "active" || { echo "$t not active"; exit 1; }
done
echo "snapper timeline and cleanup timers enabled and active"
The pacman hooks
snap-pac works by dropping alpm hooks. Assert they are actually present
rather than trusting that installing the package was enough.
hooks=$(sudo find /usr/share/libalpm/hooks -name '*snap-pac*' -o -name '*snapper*' 2>/dev/null | sort)
test -n "$hooks" || { echo "no snap-pac alpm hooks found"; exit 1; }
printf '%s\n' "$hooks"
adm brackets every invocation
snap-pac above covers pacman transactions and nothing else. A runbook does
more than install packages: it writes config files from heredocs, enables
units, installs binaries into /usr/local/bin, edits /etc. A runbook that
failed halfway therefore left a partly-changed box bracketed only around
whichever part happened to be a package install.
So adm run and adm replay take a pre/post pair of their own, around the
whole invocation. The invocation is the right unit because it is the one the
operator agreed to: adm run is what sits behind the single approval prompt in
070, so one confirmation earns one revert point.
adm verify does not snapshot — it is a read-only health check, and the
bracketing is wrapped around run and replay rather than around the shared
inner function, so it is unreachable from verify rather than merely unused.
-n skips it; --no-snapshot skips it on purpose.
The pair is tagged number with userdata adm=run, matching snap-pac's
shape so that one convention covers every snapshot on the box:
57 | pre | | ... | number | adm run 065-system-utilities.md --only btop | adm=run
58 | post | 57 | ... | number | adm run 065-system-utilities.md --only btop | adm=run
snapper status 57..58 is then the file-level truth about what that runbook
did, sitting next to the prose that claimed what it intended.
Whether EMPTY_PRE_POST_CLEANUP reaps these is unverified. Before the
filter below, an adm pair could never be empty: adm appends to
hosts/<box>/log/journal.md and writes a run log under hosts/<box>/log/runs/ while the
invocation it is bracketing is still going, so even a perfectly idempotent run
carried modified files. Measured, not assumed — the first idempotent run under
this feature produced a seven-line diff of pure bookkeeping.
With the filter in place an idempotent run reports nothing changed. Whether
snapper's empty-pair cleanup consults that filtered comparison or the raw one
is not something this runbook has established, and EMPTY_PRE_POST_MIN_AGE is
an hour, so the answer arrives by observation rather than by a test here: if
idempotent pairs start vanishing on their own, it uses the filtered view. Until
that is known, assume these pairs accumulate until NUMBER_LIMIT pushes the
oldest out — and note that limit is shared with snap-pac.
Filtering adm's own bookkeeping out of the diff
Those same writes bury the signal. Unfiltered, status across an adm pair
opens with adm's journal and run logs and journald's binary journals, before
anything the runbook actually did.
Snapper filters fix the readability without touching the snapshots: patterns in
/etc/snapper/filters/*.txt are ignored when snapper computes a difference,
while the files themselves stay in the snapshot — so restoring one by hand out
of /.snapshots/<n>/snapshot/ is unaffected. The shipped base.txt is minimal
and covers neither case.
There is deliberately no /home/* pattern here. A filter would also hide
admin's home, which is system state that a restore must cover — and an actual
person's home does not need one, because
045 makes it a subvolume and snapshots do not recurse
into those. Exclusion of user data happens one level down, structurally.
What that does not mean is that filters are cosmetic. Anything built on a
computed diff inherits them, and adm undo is built on one — so a filtered
path is out of scope for an undo as well, which is desirable for journald and
would be surprising for anything else. adm undo therefore passes the exact
path list it printed rather than letting undochange recompute the set; see
below.
snapperd reads the filters once, at startup. Writing the file changes
nothing until the daemon restarts, and because it is D-Bus activated and exits
when idle, it eventually picks the file up on its own — which is the worst of
both worlds, since the filter appears not to work and then starts working later
for no visible reason. This cost a confused ten minutes: the file was correct,
the diff was unchanged, and snapper status kept listing the very paths that
had just been excluded. So the block restarts it explicitly, with try-restart
so that a box where the daemon is not running is left alone rather than having
one started for no reason.
sudo install -d -m 0755 /etc/snapper/filters
sudo install -m 0644 /dev/stdin /etc/snapper/filters/adm.txt <<EOF
# Managed by $ADM_ROOT/runbooks/040-btrfs-snapshots.md -- overwritten on replay.
#
# Ignored when snapper computes a diff. Still present in the snapshot, so
# restoring any of these from one is unaffected.
#
# adm appends to its journal and writes a run log while the invocation it is
# bracketing is still running, so these turn up in every adm pair and never
# mean anything.
$ADM_ROOT/hosts/*/log/*
# journald writes continuously, so this lands in every pair on the box --
# including snap-pac's, which is why it is not an adm-specific problem.
/var/log/journal/*
# snapper logs the very operation that creates the pair, so it is guaranteed
# to differ inside its own snapshots. pacman logs every invocation including
# the ones that decide there is nothing to do, which is exactly what an
# idempotent runbook does all day.
/var/log/snapper.log
/var/log/pacman.log
EOF
cat /etc/snapper/filters/adm.txt
# Filters are read at daemon start, so the file alone does nothing. try-restart
# rather than restart: if snapperd is not running there is nothing to reload,
# and the next D-Bus activation reads the file anyway.
sudo systemctl try-restart snapperd.service
f=/etc/snapper/filters/adm.txt
sudo test -f "$f" || { echo "$f missing"; exit 1; }
sudo grep -qxF "$ADM_ROOT/hosts/*/log/*" "$f" \
|| { echo "adm's log dir is not filtered out of diffs"; exit 1; }
sudo grep -qxF "/var/log/journal/*" "$f" \
|| { echo "journald is not filtered out of diffs"; exit 1; }
# Assert the effect, not just the file. Fields 3, 6 and 7 are number, type and
# pre-number, all of which precede the description -- which may itself contain
# commas, since snap-pac uses the pacman command line. userdata is last, hence
# $NF.
# `read` returns non-zero at end of input, and these blocks run under
# `set -e`, so on a box with no adm pair yet this line ended the block before
# either branch below could run. That made the fallback unreachable and the
# comment explaining it wrong: it described handling a case it never saw.
# A fresh box is exactly where there is no pair -- the block installing
# snapper ran moments earlier -- and running 040 a second time succeeds,
# which is why an established box never showed it.
#
# The same shape as `grep -c` exiting 1 to report a count of zero, which did
# this to 015's encrypt-env. A command that says "nothing" by failing is a
# command that needs its own failure handled.
pre="" post=""
read -r pre post < <(sudo snapper --csvout --config root list 2>/dev/null \
| awk -F, '$6=="post" && $NF ~ /adm=run/ { p=$7; n=$3 } END { if (n) print p, n }') \
|| true
if [[ -n "${pre:-}" && -n "${post:-}" ]]; then
noise="$(sudo snapper --config root status "$pre..$post" 2>/dev/null \
| grep -E "$ADM_ROOT/hosts/[^/]*/log/|/var/log/journal/|/var/log/snapper\.log" || true)"
if [[ -n "$noise" ]]; then
echo "filter is not in effect for $pre..$post:"
printf '%s\n' "$noise" | sed 's/^/ /'
echo "snapperd reads filters at startup -- try: systemctl try-restart snapperd"
exit 1
fi
echo "filters installed and in effect; adm pair $pre..$post is free of bookkeeping"
else
# Conditional on purpose: a freshly rebuilt box has not yet run adm under a
# snapshot, and failing for want of history would be failing for the wrong
# reason.
echo "filters installed; no adm pair recorded yet to check them against"
fi
Named checkpoints
adm checkpoint "before-fuzz-upgrade" # a snapshot nothing reclaims
adm restore before-fuzz-upgrade # preview; refuses without --yes
adm restore before-fuzz-upgrade --yes # revert to it, then verify
A checkpoint is permanent because it carries no cleanup algorithm, not
because it has any special status. The hourly cleanup is
/usr/lib/snapper/systemd-helper --cleanup, which works per algorithm —
number and timeline — so a snapshot tagged with neither is never a
candidate and cannot be pushed out by NUMBER_LIMIT. The corollary is that it
will never leave on its own either: checkpoints are the operator's to delete,
which is why adm snapshots lists them in their own section rather than mixed
in with the pairs. An expiring snapshot you forget costs a slot; a permanent
one you forget costs a disk.
adm restore is adm undo with the two snapshot numbers chosen differently,
and it goes through every one of the same guards: prints the diff, refuses
without --yes, declines to run when the filters are not in effect, reverts
exactly the paths it printed, checks the result against the live filesystem,
and verifies afterwards.
One mechanical detail worth recording, because it was a deliberate choice
rather than an accident. Restoring needs two snapshots to diff, and the
second is the safety snapshot adm restore takes anyway — so the revert is
undochange <checkpoint>..<safety>, two real snapshots in the form the manual
documents. ..0, meaning the live filesystem, appears only in the read-only
preview and the post-condition check. status N..0 is demonstrably supported;
whether undochange accepts the same was never established, so nothing
depends on it.
A checkpoint is not a rollback point in the VM sense. Restoring one
replays file differences, so it cannot repair a box that will not boot —
undochange needs a running system to execute in. See
DECISIONS.md #006 for what would be required and why this
box deliberately does not have it.
And a checkpoint excludes nothing by itself. Exclusion comes from the
filter file above, which scopes every computed diff, so adding /home/* keeps
home directories out of both the diff and any restore. That works because
restore is file-level. It would not work under a true rollback, where a
subvolume is swapped whole and there is no diff to filter — which is the
coupling #006 exists to record.
Listing, undoing and pruning
adm checkpoint "<name>" # a named snapshot that never expires
adm restore <name> --yes # revert the box to one
adm snapshots # checkpoints, and adm's pairs
adm snapshots --changes # ...and how many paths each altered
adm undo 65..66 # show what reverting would do; refuses here
adm undo 65..66 --yes # revert it, then verify
adm prune --empty # pairs whose invocation changed nothing
adm prune --keep 5 --yes # keep the newest five pairs
Nothing here deletes or reverts without --yes, and prune with no selector
is an error rather than a default, so there is no spelling of it that quietly
removes everything.
adm undo wraps snapper undochange and runs adm verify afterwards, on
purpose and not optionally. A rollback reverts the box behind its own
documentation, so a verify failure after one is the expected outcome and the
useful one: it names the runbook that now claims something untrue. Re-run that
runbook, or amend it to record that the change was abandoned and why.
Three properties of it are deliberate, and each exists because the obvious implementation was not good enough:
- The undo is itself bracketed. Reversing an undo by hand means
undochange <post>..<pre>, which restores the old snapshot's version of those paths — fine the same minute, wrong a week later, when it would also discard whatever touched them in between. The bracket captures the state immediately before the undo, so undoing the undo is exact. It also makes a rollback visible: without it, an undo leaves no record anywhere. The journal records blocks, and an undo is not a block, so the only evidence would have been that files had changed. - It reverts exactly the paths it printed. The list is passed to
undochange --inputrather than letting it recompute the diff. Nothing is known to be wrong with recomputing it, which is the point:statusapplies filters, whetherundochangeapplies the same ones is undocumented, and a filter edited between the two calls would change the second set and not the first. Showing one set and reverting another is not a failure anyone would notice. - It checks the result rather than the exit status. `snapper status
- It refuses when the filters are not in effect. The undo's safety rests on
them: unfiltered, the diff includes journald's binary journals, adm's own run
logs and pacman's log, and reverting any of those is never what anyone meant.
That is not a hypothesis. Undoing the runbook that installs the filter
removes the filter; snapperd reads an empty filter directory on its next
activation; and the next diff on this box came back unfiltered, including two
systemd journals. The undo itself stayed correct only because it reverts the
exact list it printed. So
adm undonow reads the installed patterns — from the file in effect, rather than a second copy that would drift from it — and refuses in two cases: the file is missing, or the diff contains paths those patterns say should not appear, which means they are installed and snapperd has not reloaded them.
Two limits worth knowing before relying on any of this:
snapper rollbackdoes not work on this box, and will not. It creates two snapshots and swaps the default subvolume, and this box's root is the top-level subvolume (subvolid=5) rather than a nested one such as@, so there is nothing to swap. The@migration is deliberately not planned — see DECISIONS.md #003. What exists here is file-level undo, not "boot into the previous state". This was read off the layout rather than tested, because the failure mode of testing it is a box that does not boot.- Snapper does not promise consistency after an undo. Its own manual gives undoing a user creation as the example, where that user's files survive. Package installs are the sharp case: restoring files without the pacman database, or the reverse, leaves the two disagreeing. For anything that installed a package, prefer re-running the corrected runbook over undoing the old one.
End-to-end proof
The blocks above assert that snapshots are configured. This one proves the thing you actually care about: that a deleted file can be recovered.
It writes a canary, snapshots, deletes the canary, restores it from the
snapshot, compares content, then removes the snapshot and the canary. Self
cleaning, so it is safe on every replay — and it is a run block rather than
a verify block because it mutates, which verify blocks must never do.
If this block ever fails, the feature is broken no matter what the configuration says.
canary=/var/tmp/adm-snapshot-canary
token="canary-$(cat /proc/sys/kernel/random/boot_id)"
printf '%s\n' "$token" | sudo tee "$canary" >/dev/null
num=$(sudo snapper -c root create --print-number \
--description "adm: snapshot round-trip self-test")
echo "created snapshot #$num"
sudo rm -f "$canary"
test ! -e "$canary" || { echo "canary survived deletion?"; exit 1; }
src="/.snapshots/$num/snapshot$canary"
sudo test -f "$src" || { echo "canary not present in snapshot #$num at $src"; exit 1; }
sudo cp -a "$src" "$canary"
restored="$(sudo cat "$canary")"
test "$restored" = "$token" \
|| { echo "restored content mismatch: '$restored' != '$token'"; exit 1; }
echo "restored from snapshot #$num and content matches"
sudo snapper -c root delete "$num"
sudo rm -f "$canary"
echo "cleaned up: snapshot #$num and canary removed"
Using it
snapper -c root list # every snapshot
snapper -c root status 42..43 # what changed between two
snapper -c root diff 42..43 /etc/fstab # how a file changed
sudo cp /.snapshots/42/snapshot/etc/foo /etc/foo # restore one file
snapper -c root create -d "before something risky" # manual, before risk
After a regrettable pacman -Syu, snapper -c root list shows the pre/post
pair that snap-pac made, and status between them is the exact set of files
the transaction touched.
Next, if this proves useful
grub-btrfs, which adds boot entries for snapshots. Still worthwhile: booting into a snapshot to inspect or repair an unbootable box is faster than a hypervisor-level restore, and non-destructive.- Off-box
btrfs sendand the@subvolume migration are both explicitly not planned — the hypervisor tier covers what they would provide, better. See DECISIONS.md #003.
045 — User homes are subvolumes of their own
Optional — a box opts in by adding 045 to OPTIONAL_RUNBOOKS in its <box>.env.
Give each person's home its own subvolume and its own snapshots, so rolling the system back does not take their work with it and they keep control of their own history.
Every snapshot 040 takes covers the root tree, and
that included /home entirely — so adm undo or adm restore would revert a
person's files along with the system's. For the admin account that is
correct and deliberate: admin is the operator, its home holds the box's own
state including the age key from 015, and it belongs inside
the system's snapshots.
For anyone else with an account it is wrong twice over. Their files are not the box's business, and they should be able to snapshot and roll back their own home without being able to touch the system's.
How the exclusion works
btrfs snapshots do not recurse into child subvolumes. So a /home/<user>
that is its own subvolume is excluded from every root snapshot — adm pairs,
checkpoints, timeline and snap-pac alike — structurally, with nothing to
configure and nothing to forget. It is the same property
DECISIONS.md #006 leans on when it says exclusion requires a
subvolume rather than a pattern.
/home itself stays an ordinary directory. Only the homes beneath it are
subvolumes, which is all the exclusion needs and avoids migrating anything.
Note what this means for /etc/snapper/filters/adm.txt: there is deliberately
no /home/* pattern in it. A filter would also hide admin's home, which
is exactly the system state restores must cover, and non-admin homes need no
filter because they are excluded one level down.
Who is in scope
$MACHINE_USERS from <box>.env, space separated, each entry name:uid:
MACHINE_USERS="ryan:2001 buildbot:2002"
An empty list is normal and these blocks do nothing.
The uid is required, not optional. useradd would otherwise take the next
free number from UID_MIN, which makes it a function of creation order — and
this runbook is the one place where that bites hardest. A machine user's home
is deliberately outside the system's snapshots, so on a rebuild their data
comes from their own backup, and a tar or btrfs send stream carries numeric
owners. A box that hands out a different number restores those files to the
wrong account. So 045's "a rebuild does not bring it back" is worse than it
reads unless the number is pinned: the data would be absent and the ownership
it returned with would be unpredictable.
It also matters to the reproduction test owed in
DECISIONS.md #001. hosts/<box>/facts/users.txt records the whole
passwd line, so a replay that numbers accounts differently shows up as drift —
detectable, which is good, but the drift is a symptom of a decision nobody
wrote down.
The group is created with the same number, so uid == gid and neither depends
on order. A uid already held by a different account is refused, and an existing
account whose uid disagrees with the list is refused too rather than
renumbered: changing the uid of an account that owns files is a manual job.
The name is deliberately not HUMAN_USERS, which it was for about an hour. The
line this runbook draws is not between people and software — it is between the
operator account, whose home is part of the box, and every other account
that owns its own home and its own history. A service account, a CI runner or
an agent with somewhere to keep state belongs on this list exactly as much as a
person does, and for the same reason: a system rollback should not reach into
it, and its owner should be able to roll it back without reaching into the
system.
$ADMIN_USER must not appear in it, and the block refuses if it does. A box
that excluded the admin home would quietly stop snapshotting its own
configuration and secrets while appearing to work.
Order matters: subvolume before account
The subvolume has to exist before the home is populated. Converting a populated directory into a subvolume means copying it out, replacing it and copying it back with the user logged out — not something a replay should attempt unattended. So this creates the subvolume and then the account, and if it finds an existing user whose home is a plain directory it refuses and says so. That conversion is a decision for a human at a keyboard.
pam_snapper ships with snapper and automates roughly this, including a
pam_snapper_homeconvert.sh. It is not used here: its helpers call
/usr/sbin/pam-config, which is SUSE's and does not exist on Arch, and its PAM
hooks snapshot on every login, which is noise rather than a feature. The idea
is borrowed; the code is not.
What this does not do
Excluding a home from the system's snapshots means a system rollback leaves it alone, which is the point. It also means a rebuild does not bring it back: replaying this runbook recreates the account and an empty subvolume, nothing else. User data is the user's own problem, and the hypervisor tier in DECISIONS.md #003 covers the disk rather than any particular file's history.
users="${MACHINE_USERS:-}"
if [[ -z "${users// /}" ]]; then
echo "MACHINE_USERS is empty; no per-user homes to manage"
exit 0
fi
for entry in $users; do
u="${entry%%:*}"
uid="${entry#*:}"
if [[ "$u" == "$entry" || -z "$uid" || ! "$uid" =~ ^[0-9]+$ ]]; then
echo "MACHINE_USERS entry '$entry' carries no numeric uid."
echo "Write it as name:uid. The uid is pinned deliberately -- see this runbook."
exit 1
fi
if [[ "$u" == "$ADMIN_USER" ]]; then
echo "$ADMIN_USER is the admin account: its home is system state and has"
echo "to stay inside the root snapshots. Remove it from MACHINE_USERS."
exit 1
fi
home="/home/$u"
cfg="home-$u"
# A uid already spoken for is a collision, not a no-op.
owner="$(getent passwd "$uid" 2>/dev/null | cut -d: -f1 || true)"
if [[ -n "$owner" && "$owner" != "$u" ]]; then
echo "uid $uid already belongs to '$owner', not '$u'"
exit 1
fi
# An account that owns files cannot be renumbered safely, so say so.
if id -u "$u" >/dev/null 2>&1 && [[ "$(id -u "$u")" != "$uid" ]]; then
echo "$u exists with uid $(id -u "$u") but MACHINE_USERS declares $uid."
echo "Renumbering an account that owns files is a manual job and this"
echo "runbook will not attempt it."
exit 1
fi
if id -u "$u" >/dev/null 2>&1 \
&& ! sudo btrfs subvolume show "$home" >/dev/null 2>&1; then
echo "$u already exists and $home is a plain directory."
echo "Converting a populated home needs the user logged out and the data"
echo "copied out and back; this runbook will not do that unattended."
exit 1
fi
sudo btrfs subvolume show "$home" >/dev/null 2>&1 \
|| sudo btrfs subvolume create "$home"
# gid is pinned equal to uid, so neither number depends on creation order.
getent group "$u" >/dev/null 2>&1 || sudo groupadd --gid "$uid" "$u"
id -u "$u" >/dev/null 2>&1 \
|| sudo useradd --uid "$uid" --gid "$u" --home-dir "$home" \
--no-create-home --shell /bin/bash "$u"
# useradd declines to populate an existing directory, so skel is copied here
# -- but only into an empty home, or a replay would overwrite the user's own
# dotfiles with the defaults every time.
if [[ -z "$(sudo ls -A "$home")" ]]; then
sudo cp -a /etc/skel/. "$home"/
fi
sudo chown -R "$u:$u" "$home"
sudo chmod 0700 "$home"
sudo snapper --config "$cfg" list >/dev/null 2>&1 \
|| sudo snapper --config "$cfg" create-config "$home"
# TIMELINE_CREATE is yes by default, which would take hourly snapshots of
# someone's home without asking. The premise here is that the user decides.
sudo snapper --config "$cfg" set-config \
"ALLOW_USERS=$u" "SYNC_ACL=yes" "TIMELINE_CREATE=no"
echo "$u ($uid:$uid): $home is a subvolume; snapper config '$cfg' is theirs"
done
users="${MACHINE_USERS:-}"
declared=""
for entry in $users; do declared="$declared ${entry%%:*}"; done
# Orphans first, because this is the half the run block cannot do. It creates
# and never removes: drop a name from MACHINE_USERS and the account, its home
# and its config stay. Converging by deleting them would destroy exactly the
# data this runbook exists to keep out of the system's reach, so the drift is
# reported and left for a human.
shopt -s nullglob
for f in /etc/snapper/configs/home-*; do
n="$(basename "$f")"
n="${n#home-}"
case " $declared " in *" $n "*) continue ;; esac
echo "snapper config 'home-$n' exists but '$n' is not in MACHINE_USERS"
echo "declare it again, or remove the account, its home and the config"
exit 1
done
shopt -u nullglob
if [[ -z "${users// /}" ]]; then
echo "MACHINE_USERS is empty; no declared users and no orphans"
exit 0
fi
for entry in $users; do
u="${entry%%:*}"
uid="${entry#*:}"
home="/home/$u"
cfg="home-$u"
id -u "$u" >/dev/null 2>&1 || { echo "$u does not exist"; exit 1; }
# The pinned numbers, which are the whole reason the uid is in the list.
test "$(id -u "$u")" = "$uid" \
|| { echo "$u has uid $(id -u "$u"); MACHINE_USERS declares $uid"; exit 1; }
test "$(id -g "$u")" = "$uid" \
|| { echo "$u has gid $(id -g "$u"); uid and gid are pinned equal"; exit 1; }
sudo btrfs subvolume show "$home" >/dev/null 2>&1 \
|| { echo "$home is not a subvolume, so root snapshots still contain it"
exit 1; }
sudo test -f "/etc/snapper/configs/$cfg" \
|| { echo "no snapper config '$cfg'"; exit 1; }
# Behaviour, not configuration: the user must be able to work their own
# config with no privilege at all. That is what ALLOW_USERS and SYNC_ACL are
# for, and reading the file back would only prove the file was written.
sudo -u "$u" snapper --config "$cfg" list >/dev/null 2>&1 \
|| { echo "$u cannot use snapper config '$cfg' unprivileged"; exit 1; }
# And the boundary in the other direction, which is the half that matters.
if sudo -u "$u" snapper --config root list >/dev/null 2>&1; then
echo "$u can read the root snapper config; the grant is too broad"
exit 1
fi
echo "$u ($uid:$uid): $home excluded from root snapshots; '$cfg' theirs, root denied"
done
Proving it rather than asserting it
The blocks above do nothing while MACHINE_USERS is empty, which is the normal
state of a box with one operator and no people on it — and an untested path is
worth little on the day it is finally needed. So this proves the whole
mechanism against a throwaway account on every replay, the same way
040's snapshot-roundtrip proves restore rather
than asserting configuration.
Three claims, each failing loudly: a root snapshot cannot see inside the home, the user can snapshot their own, and the user cannot reach the system's.
u=admhometest
home="/home/$u"
cfg="home-$u"
# A previous failure leaves debris on purpose -- loud evidence beats silent
# cleanup -- so start by clearing it.
sudo snapper --config "$cfg" delete-config >/dev/null 2>&1 || true
id -u "$u" >/dev/null 2>&1 && sudo userdel "$u" >/dev/null 2>&1 || true
sudo btrfs subvolume show "$home" >/dev/null 2>&1 \
&& sudo btrfs subvolume delete "$home" >/dev/null || true
sudo rm -rf "$home"
sudo btrfs subvolume create "$home" >/dev/null
sudo useradd --home-dir "$home" --no-create-home --shell /bin/bash "$u"
sudo chown "$u:$u" "$home"
sudo chmod 0700 "$home"
sudo snapper --config "$cfg" create-config "$home"
sudo snapper --config "$cfg" set-config \
"ALLOW_USERS=$u" "SYNC_ACL=yes" "TIMELINE_CREATE=no"
# 1. the exclusion itself. No cleanup algorithm on this snapshot, so nothing
# can reap it mid-test.
num=$(sudo snapper --config root create --print-number \
--description "adm: user-home exclusion self-test")
canary="$home/exclusion-canary"
printf 'canary\n' | sudo tee "$canary" >/dev/null
if sudo snapper --config root status "$num..0" | grep -qF "$canary"; then
echo "FAIL: $canary shows in a root snapshot diff; the home is not excluded"
exit 1
fi
echo "a root snapshot cannot see inside $home"
# 2. the user's own control, exercised as the user.
snum=$(sudo -u "$u" snapper --config "$cfg" create --print-number \
--description "roundtrip self-test")
test -n "$snum" || { echo "FAIL: $u could not snapshot their own home"; exit 1; }
echo "$u snapshotted their own home unprivileged (#$snum)"
# 3. and the boundary.
if sudo -u "$u" snapper --config root list >/dev/null 2>&1; then
echo "FAIL: $u can read the root snapper config"
exit 1
fi
echo "$u cannot reach the system's snapshots"
sudo -u "$u" snapper --config "$cfg" delete "$snum"
sudo snapper --config root delete "$num"
# delete-config removes the config from snapper's registry in
# /etc/conf.d/snapper and deletes the .snapshots subvolume, but leaves
# /etc/snapper/configs/<name> behind. snapper then reports the config as
# unknown while the file sits in /etc as undeclared drift -- which this block
# produced twice before anyone looked, while printing "cleaned up". So the
# file is removed explicitly and its absence asserted, because a cleanup that
# is trusted rather than checked is how the debris got there.
sudo snapper --config "$cfg" delete-config
sudo rm -f "/etc/snapper/configs/$cfg"
sudo test -e "/etc/snapper/configs/$cfg" \
&& { echo "FAIL: /etc/snapper/configs/$cfg survived cleanup"; exit 1; } || true
sudo userdel "$u"
sudo btrfs subvolume delete "$home" >/dev/null
sudo rm -rf "$home"
test ! -e "$home" || { echo "FAIL: $home survived cleanup"; exit 1; }
echo "cleaned up and checked: $u, $home, config '$cfg', snapshot #$num"
Using it
A person with an account snapshots their own home with no privilege:
snapper -c home-ryan create -d "before the big refactor"
snapper -c home-ryan list
snapper -c home-ryan status 3..0
snapper -c home-ryan undochange 3..0
Their snapshots carry no cleanup algorithm, so nothing reclaims them; they
expire when their owner says so. adm snapshots, adm undo and
adm restore do not touch any of this — they work the root config only.
050 — Boot into snapshots (grub-btrfs)
Optional — a box opts in by adding 050 to OPTIONAL_RUNBOOKS in its <box>.env.
Add GRUB menu entries for every btrfs snapshot, so a box that will not boot can be booted as it was to inspect or repair it.
Depends on 040 for the snapshots themselves, and
on GRUB — not on a particular firmware. BIOS and UEFI both work, because
this runbook only ever calls grub-mkconfig, never grub-install, and Arch
puts grub.cfg at /boot/grub/grub.cfg on both. What it cannot work with is
systemd-boot, which has no snapshot-boot equivalent.
What this is for, and what it is not
The recovery tier is the hypervisor (DECISIONS.md #003) — whole-disk, off-box, authoritative. This is the cheap tier: booting a snapshot is non-destructive, takes one reboot, and leaves the live filesystem untouched, which makes it the right first move for "what did that upgrade break?" A hypervisor restore is the right move once you know.
So this is a diagnostic convenience, not a durability feature.
Read this before relying on it: selection needs the console
Generating snapshot entries is the easy half. Choosing one is the half that constrains this box.
The obvious headless mechanism is grub-reboot, which sets a one-shot
next_entry in grubenv so the next boot lands on a chosen entry and all
later boots revert. It does not work here, and using it would be a trap.
GRUB's generated config has the machinery, but it needs env_block set to
reach grubenv by raw block list, bypassing the filesystem driver:
if [ "${env_block}" ] ; then
set env_block="(${root})${env_block}" # <- only ever prefixes; never assigned
load_env -f "${env_block}"
fi
...
if [ "${env_block}" ] ; then
save_env -f "${env_block}" next_entry # blocklist write: would work
else
save_env next_entry # filesystem write: fails on btrfs
fi
env_block is never assigned on this box, so the second branch is taken —
and GRUB's btrfs module is read-only. The blocklist workaround is not
available either, because grubenv is stored inline in btrfs metadata
rather than in its own extent:
File size of /boot/grub/grubenv is 1024 (1 block of 4096 bytes)
0: 0.. 4095: 0.. 4095: 4096: last,encoded,not_aligned,inline,eof
The failure mode is nasty rather than obvious: grub-reboot would set
next_entry (that write is done by the OS, which can write btrfs fine), GRUB
would boot that entry, then fail silently to clear it — so the box would
boot that snapshot on every subsequent boot until someone noticed. On a
machine administered as though it has no console, that is how you lose a box.
So selection is via the GRUB menu, over the serial console. This image is
already wired for it — GRUB_TERMINAL="serial console",
GRUB_SERIAL_COMMAND="serial --unit=0 --speed=115200", and the kernel gets
console=ttyS0,115200. The hypervisor just has to expose the serial port.
That makes this runbook an honest exception to the no-console policy: it installs a tool whose use requires console access. Worth having anyway, because the situation where you want it — a box that will not boot — is one where you are already at the console.
Preconditions
test "$(findmnt -no FSTYPE /)" = "btrfs" || { echo "root is not btrfs"; exit 1; }
# GRUB, not the firmware, is the requirement. BIOS and UEFI both work: this
# runbook only ever calls grub-mkconfig, never grub-install, and Arch puts
# grub.cfg at /boot/grub/grub.cfg either way.
if ! command -v grub-mkconfig >/dev/null || [[ ! -d /boot/grub ]]; then
echo "GRUB is not the bootloader on this box."
if sudo bootctl is-installed >/dev/null 2>&1; then
echo "It appears to use systemd-boot, which has no snapshot-boot"
echo "equivalent: grub-btrfs generates GRUB menu entries and nothing else"
echo "does. Either install GRUB, or accept that boot-into-snapshot is"
echo "unavailable here -- 040's file-level restore still works, and the"
echo "hypervisor tier still covers whole-VM rollback."
fi
exit 1
fi
sudo test -f /boot/grub/grub.cfg || { echo "no /boot/grub/grub.cfg"; exit 1; }
# A snapshot must contain the kernel and initramfs it was taken with, or a
# snapshot boot pairs an old root with the current kernel. That holds only
# while /boot is inside the root filesystem -- a UEFI install mounting the ESP
# at /boot breaks it, on otherwise identical hardware.
if boot_src="$(findmnt -no SOURCE /boot 2>/dev/null)"; then
test "$boot_src" = "$(findmnt -no SOURCE /)" || {
echo "/boot is a separate filesystem ($boot_src)."
echo "Snapshots would not contain the kernel they were taken with."
exit 1; }
fi
sudo btrfs subvolume show /.snapshots >/dev/null 2>&1 \
|| { echo "/.snapshots missing -- run 040 first"; exit 1; }
if [[ -d /sys/firmware/efi ]]; then fw=UEFI; else fw="BIOS"; fi
echo "btrfs root, GRUB $(pacman -Q grub | awk '{print $2}') on $fw, /boot on the root fs, /.snapshots present"
The package
inotify-tools is listed as an optional dependency of grub-btrfs, and
is mandatory for anything here: grub-btrfsd is a shell script built around
inotifywait, and without it the daemon exits 1 after about eleven
milliseconds. Installing grub-btrfs alone gets you a regeneration daemon
that never runs.
| package | why |
|---|---|
grub-btrfs |
the 41_snapshots-btrfs generator and grub-btrfsd |
inotify-tools |
optional per the package, required for the daemon to start at all |
sudo pacman -S --needed --noconfirm grub-btrfs inotify-tools
pacman -Qi grub-btrfs >/dev/null || { echo "grub-btrfs missing"; exit 1; }
command -v inotifywait >/dev/null \
|| { echo "inotifywait missing -- grub-btrfsd cannot start"; exit 1; }
sudo test -x /etc/grub.d/41_snapshots-btrfs \
|| { echo "snapshot generator script missing"; exit 1; }
echo "grub-btrfs $(pacman -Q grub-btrfs | awk '{print $2}'), inotify-tools present"
Make a booted snapshot usable
Snapper's snapshots are read-only (ro=true), and a read-only root is not
much use: systemd, journald and most of userspace expect to write. grub-btrfs
ships an initramfs hook that mounts the snapshot read-only and layers a
writable tmpfs overlay on top, so the booted system behaves normally and
nothing you do touches the snapshot.
Added as a drop-in rather than by editing HOOKS in
/etc/mkinitcpio.conf. That file belongs to the mkinitcpio package; editing
it invites .pacnew reconciliation forever, and /etc/mkinitcpio.conf.d/
exists precisely so we do not have to. (mkinitcpio here is 42.2; the drop-in
directory arrived in 38.)
sudo install -d -m 0755 /etc/mkinitcpio.conf.d
sudo install -m 0644 /dev/stdin /etc/mkinitcpio.conf.d/grub-btrfs-overlayfs.conf <<'EOF'
# Managed by /opt/admin/runbooks/050-grub-btrfs.md
# Lets a read-only btrfs snapshot boot as a usable system: the snapshot is
# mounted ro and a writable tmpfs is overlaid, so a snapshot boot cannot
# modify the snapshot. Without this, booting a snapper snapshot yields a
# read-only root and a broken userspace.
HOOKS+=(grub-btrfs-overlayfs)
EOF
sudo mkinitcpio -P
sudo test -f /etc/mkinitcpio.conf.d/grub-btrfs-overlayfs.conf \
|| { echo "mkinitcpio drop-in missing"; exit 1; }
# The hook must actually be inside the built image, not merely configured.
sudo lsinitcpio /boot/initramfs-linux.img 2>/dev/null \
| grep -q "grub-btrfs-overlayfs" \
|| { echo "overlayfs hook is NOT present in /boot/initramfs-linux.img"; exit 1; }
echo "grub-btrfs-overlayfs hook present in the initramfs"
A menu you can actually catch
GRUB_TIMEOUT is 1 second on this image. That is sensible for a server that
always boots the same thing, and useless for picking a snapshot over a serial
line. Five seconds is long enough to interrupt and short enough not to annoy.
The cost is real and worth stating: every boot gets four seconds slower.
That is the price of snapshot booting being possible at all here, given
grub-reboot is unavailable.
Again a drop-in — grub-mkconfig sources /etc/default/grub.d/*.cfg, so
/etc/default/grub stays pristine and .pacnew-free.
sudo install -d -m 0755 /etc/default/grub.d
sudo install -m 0644 /dev/stdin /etc/default/grub.d/10-snapshot-menu.cfg <<'EOF'
# Managed by /opt/admin/runbooks/050-grub-btrfs.md
# 1s (the image default) is too short to interrupt over a serial console, and
# selecting a snapshot entry by hand is the only way to boot one on this box:
# grub-reboot cannot work, because GRUB cannot write grubenv on btrfs.
GRUB_TIMEOUT=5
GRUB_TIMEOUT_STYLE=menu
EOF
cat /etc/default/grub.d/10-snapshot-menu.cfg
sudo test -f /etc/default/grub.d/10-snapshot-menu.cfg || { echo "drop-in missing"; exit 1; }
echo "timeout drop-in present (effect asserted in grub-regenerate)"
Generate the menu
sudo grub-mkconfig -o /boot/grub/grub.cfg
cfg=/boot/grub/grub.cfg
sudo grep -q "set timeout=5" "$cfg" \
|| { echo "timeout drop-in did not take effect"; exit 1; }
sudo grep -qi "snapshots" "$cfg" \
|| { echo "no snapshot entries in grub.cfg -- is 41_snapshots-btrfs executable?"; exit 1; }
n=$(sudo grep -cE "^\s*menuentry" "$cfg")
echo "grub.cfg: timeout 5, $n menuentries, snapshot submenu present"
The regeneration daemon
Snapshot entries go stale the moment a snapshot is created or deleted — which,
with snap-pac and an hourly timeline, is constantly. grub-btrfsd watches
the snapshot directory and regenerates the menu on change.
No unit override is needed: the shipped service already watches the snapper
location (ExecStart=/usr/bin/grub-btrfsd --syslog /.snapshots).
The start is followed by a settle-and-recheck, which is not padding.
systemctl is-active immediately after enable --now returned active for
a daemon that had already exited — systemd had not yet reaped it. The missing
inotify-tools above was therefore reported as a success at run time and only
caught by the next adm verify. Any long-running service is worth
re-checking after a pause, or the block cheerfully certifies a corpse.
sudo systemctl enable --now grub-btrfsd.service
sleep 2
if ! systemctl is-active --quiet grub-btrfsd.service; then
echo "grub-btrfsd did not stay running:"
systemctl status grub-btrfsd.service --no-pager -l | tail -8
exit 1
fi
systemctl show -p MainPID -p ActiveState --value grub-btrfsd.service | paste -sd' '
test "$(systemctl is-enabled grub-btrfsd.service)" = "enabled" \
|| { echo "grub-btrfsd not enabled"; exit 1; }
test "$(systemctl is-active grub-btrfsd.service)" = "active" \
|| { echo "grub-btrfsd not running: $(systemctl is-failed grub-btrfsd.service)"; exit 1; }
pid="$(systemctl show -p MainPID --value grub-btrfsd.service)"
test "${pid:-0}" -gt 0 || { echo "grub-btrfsd has no main process"; exit 1; }
test -d "/proc/$pid" || { echo "grub-btrfsd main pid $pid is not alive"; exit 1; }
echo "grub-btrfsd enabled, active, pid $pid, watching /.snapshots"
Assert the snapshot entries would actually boot a snapshot
A generated entry inherits the normal kernel command line and then appends its
own rootflags, so every snapshot entry carries two:
linux "/.snapshots/6/snapshot/boot/vmlinuz-linux" root=UUID=a51fe3b5… \
rootflags=compress-force=zstd console=tty0 console=ttyS0,115200 \
rootflags=subvol=".snapshots/6/snapshot"
The kernel's rootflags= handler copies the value into a single buffer, so
repeated occurrences overwrite and the last one wins. Today the subvol=
flag is last, so the snapshot is what gets mounted — correct, but correct by
ordering, which is a thin guarantee. If GRUB_CMDLINE_LINUX_DEFAULT ever
grew its own trailing rootflags, every snapshot entry would quietly boot the
live root while still being labelled as a snapshot. A booted snapshot that is
secretly the live system is the worst possible outcome for a tool you reach for
during an incident.
So assert it rather than trust it: for every snapshot entry, the last
rootflags must select a subvolume.
One consequence of last-wins, worth knowing and not worth fixing:
compress-force=zstd is dropped during a snapshot boot. It does not matter,
because the overlayfs hook sends all writes to a tmpfs — nothing is written to
btrfs to compress.
cfg=/boot/grub/grub-btrfs.cfg
sudo test -s "$cfg" || { echo "$cfg missing or empty -- run grub-mkconfig"; exit 1; }
n=0; bad=0
while IFS= read -r line; do
n=$((n+1))
last="$(grep -oE 'rootflags=[^ ]*' <<<"$line" | tail -1)"
case "$last" in
*subvol=*) : ;;
*) echo "entry $n: last rootflags does not select a subvolume: ${last:-<none>}"
bad=$((bad+1)) ;;
esac
grep -q 'linux "/\.snapshots/' <<<"$line" \
|| { echo "entry $n: kernel is not loaded from a snapshot path"; bad=$((bad+1)); }
done < <(sudo grep -E '^[[:space:]]+linux[[:space:]]' "$cfg")
test "$n" -gt 0 || { echo "no snapshot boot entries found in $cfg"; exit 1; }
test "$bad" -eq 0 || { echo "$bad problem(s) across $n entries"; exit 1; }
echo "$n snapshot entries, each loading its kernel from the snapshot and ending with a subvol rootflags"
Guard against the grub-reboot trap
If anyone ever runs grub-reboot on this box — reasonably, since it works
nearly everywhere else — next_entry gets written and GRUB can never clear
it, so the box boots that entry on every boot thereafter. The symptom is
baffling if you do not know to look.
This turns that trap into a detectable condition, checked on every
adm verify, with the fix in the failure message.
env_list="$(sudo grub-editenv /boot/grub/grubenv list 2>/dev/null || true)"
if grep -q '^next_entry=' <<<"$env_list"; then
echo "DANGER: next_entry is set in grubenv:"
grep '^next_entry=' <<<"$env_list"
echo
echo "GRUB cannot clear this on btrfs, so this box will boot that entry on"
echo "EVERY boot until it is removed. Clear it with:"
echo " sudo grub-editenv /boot/grub/grubenv unset next_entry"
exit 1
fi
echo "no stale next_entry in grubenv"
Using it
- Open the VM's serial console at the hypervisor.
- Reboot. Interrupt the five-second countdown.
- Pick Arch Linux snapshots, then the snapshot by date and description —
snap-paclabels its entries with thepacmancommand line that caused them, which is usually enough to identify the one you want. - The snapshot boots read-only with a writable overlay. Changes you make are discarded on the next reboot, so investigate freely.
- To recover: copy what you need out, reboot into the normal entry, and put it back. Or decide the damage warrants a hypervisor restore.
Note what a snapshot boot does not include: nested subvolumes appear empty,
because btrfs snapshots are not recursive. In particular /swap is empty, so
swap activation fails with a harmless journal error, and /.snapshots is
empty — you cannot reach other snapshots from inside a booted one.
060 — Development packages
Required — every box runs this.
The tooling this box needs to be a workstation rather than a server.
Everything up to 050 was about making the box manageable. This is the
first runbook about making it useful — and specifically about making it a
host for an agent harness (070), which is what this
machine exists for.
Why these, and why a table
Every package earns a line explaining why it is here. That rule exists because
package lists rot into archaeology otherwise: six months on, nobody knows
whether fzf is load-bearing or was installed once to try something. A name
with no reason attached cannot be safely removed, so it never is.
| package | why |
|---|---|
base-devel |
compilers, make, fakeroot. Needed by anything built from source, and a prerequisite for AUR packages if that ever becomes necessary |
tmux |
detachable sessions. The load-bearing one for 070: an agent run that outlives an SSH connection has to be detachable, or every dropped link kills the work |
ripgrep |
fast search. Already pulled in as an opencode dependency, listed explicitly because this repo also relies on it directly |
fd |
fast file discovery; the companion to ripgrep that agents and humans both reach for |
fzf |
interactive filtering, including over adm list output |
neovim |
an editor on the box. sops needs $EDITOR to edit encrypted files in place |
man-db |
manual pages. An agent that can read man sops makes fewer things up |
bash-completion |
completions generally, and opencode ships a bash completion that is inert without it |
github-cli |
gh, for forge interaction that is awkward over plain git |
Deliberately not here: nodejs, npm, bun. opencode is a
self-contained binary depending only on curl, glibc, icu, ripgrep and
tar, so a JavaScript runtime would be dead weight installed on a guess. Add
one when something actually needs it, with a line in this table saying what.
sudo pacman -S --needed --noconfirm \
base-devel tmux ripgrep fd fzf neovim man-db bash-completion github-cli
missing=()
for p in base-devel tmux ripgrep fd fzf neovim man-db bash-completion github-cli; do
pacman -Qi "$p" >/dev/null 2>&1 || pacman -Qg "$p" >/dev/null 2>&1 || missing+=("$p")
done
test "${#missing[@]}" -eq 0 || { echo "missing: ${missing[*]}"; exit 1; }
# Commands, not just packages -- a package can install and still not be usable.
for c in tmux rg fd fzf nvim man gh; do
command -v "$c" >/dev/null || { echo "command not on PATH: $c"; exit 1; }
done
echo "dev packages present: tmux $(tmux -V | awk '{print $2}'), rg $(rg --version | head -1 | awk '{print $2}'), nvim $(nvim --version | head -1 | awk '{print $2}'), gh $(gh --version | head -1 | awk '{print $3}')"
An editor for sops
sops <file> decrypts to a temporary file, opens $EDITOR, and re-encrypts
on save — the only way to edit a secret without it ever touching the disk in
clear. With $EDITOR unset, sops falls back to vi, which exists but is
not what is installed here deliberately.
Set at the shell level rather than in <box>.env, because it is a preference
of whoever is logged in, not a property of the machine. Written as a drop-in
so no package-managed file is edited.
sudo install -d -m 0755 /etc/profile.d
sudo install -m 0644 /dev/stdin /etc/profile.d/10-editor.sh <<'EOF'
# Managed by /opt/admin/runbooks/060-dev-packages.md
# sops opens $EDITOR to edit encrypted files in place; without this it falls
# back to vi. Only set if the session has not chosen otherwise.
: "${EDITOR:=nvim}"
: "${VISUAL:=nvim}"
export EDITOR VISUAL
EOF
bash -lc 'echo "EDITOR=$EDITOR"'
sudo test -f /etc/profile.d/10-editor.sh || { echo "editor drop-in missing"; exit 1; }
got="$(bash -lc 'echo -n "$EDITOR"')"
test "$got" = "nvim" || { echo "EDITOR is '$got' in a login shell, expected nvim"; exit 1; }
command -v "$got" >/dev/null || { echo "EDITOR=$got is not installed"; exit 1; }
echo "login shells get EDITOR=$got"
065 — System utilities
Required — every box runs this.
Lightweight tools for daily system administration and monitoring.
This runbook adds tools that are useful on a workstation but don't need the
full development stack. Everything before this was either about making the box
manageable (000-050) or about building the agent harness (060, 070).
This is about making the box pleasant to use for routine administration.
Why btop
btop is a terminal-based resource monitor that shows CPU, memory, disk, and
network usage in a clean, interactive interface. It's more readable than top
or htop, written in C++ with no runtime dependencies beyond libuv and ncurses.
The agent harness often needs to watch system resources during long runs, and
btop makes that obvious rather than parsing text output.
The package
sudo pacman -S --needed --noconfirm unzip
pacman -Qi unzip >/dev/null || { echo "unzip not installed"; exit 1; }
command -v unzip >/dev/null || { echo "unzip not on PATH"; exit 1; }
echo "unzip installed"
sudo pacman -S --needed --noconfirm btop
pacman -Qi btop >/dev/null || { echo "btop not installed"; exit 1; }
command -v btop >/dev/null || { echo "btop not on PATH"; exit 1; }
echo "btop $(btop --version | head -1) installed"
A wrapper for compatibility
Typing btop in a non-tty context (like an agent run with output captured)
shows the help rather than failing. Since top is already provided by
procps-ng, create a wrapper script at /usr/local/bin/top that invokes
btop instead, giving a familiar command name while leaving the original
top available for the rare case where someone needs the SysV version.
sudo install -d -m 0755 /usr/local/bin
sudo install -m 0755 /dev/stdin /usr/local/bin/top <<'EOF'
#!/usr/bin/env bash
# Managed by /opt/admin/runbooks/065-system-utilities.md
# Invoke btop instead of the sysv top command for a better interface.
exec btop "${@:-}"
EOF
sudo install -m 0755 /dev/stdin /usr/local/bin/htop <<'EOF'
#!/usr/bin/env bash
# Managed by /opt/admin/runbooks/065-system-utilities.md
# Invoke btop instead of htop for consistency.
exec btop "${@:-}"
EOF
command -v top
test -x /usr/local/bin/top || { echo "/usr/local/bin/top not executable"; exit 1; }
test -x /usr/local/bin/htop || { echo "/usr/local/bin/htop not executable"; exit 1; }
# Both should invoke btop (compare content since they're scripts, not symlinks)
grep -q 'exec btop' /usr/local/bin/top || { echo "top wrapper doesn't exec btop"; exit 1; }
grep -q 'exec btop' /usr/local/bin/htop || { echo "htop wrapper doesn't exec btop"; exit 1; }
got="$(/usr/local/bin/top --version 2>&1 | head -1 | sed 's/\x1b\[[0-9;]*m//g')"
test "$got" = "btop version: 1.4.7" || { echo "unexpected version output: $got"; exit 1; }
echo "top and htop are btop wrappers"
Next
| runbook | what it does | needs |
|---|---|---|
070-agent-harness.md |
install the opencode agent harness so the box can maintain itself | 060, 065 |
070 — Agent harness
Optional — a box opts in by adding 070 to OPTIONAL_RUNBOOKS in its <box>.env.
Put a gated LLM agent on the box, so a change can be asked for in prose and arrive as a runbook that ran, rather than as commands nobody wrote down.
Everything before this runbook was driven by an agent harness on a different
machine, reaching in over SSH. That works, but it makes the repo something
done to this box rather than something the box does. This runbook closes the
loop: the machine gets its own harness, this repo's conventions as a skill,
and the secrets to use a model — and from then on
/adm <describe a desired outcome> is the interface for system maintenance
that documents itself as a side effect.
Blocked: this VM's CPU is too old for the agent
opencode is compiled with Bun, which requires AVX2 on x86-64. This VM
presents Proxmox's default kvm64 model, which is barely x86-64-v1:
model name : Common KVM processor
avx MISSING avx2 MISSING sse4_2 MISSING popcnt MISSING bmi2 MISSING
The failure mode is nasty rather than obvious: opencode does not report an
illegal instruction, it spins at 100% CPU producing no output and never
exits — which reads like a hang or a network problem rather than a missing
instruction set.
The fix is a hypervisor-side change, outside what any runbook here can do:
set the VM's CPU type to host (or at least x86-64-v3) in Proxmox and
reboot. host is the better default for a development box regardless — kvm64
also costs you AES-NI and a decade of other extensions.
The precondition below fails until that is done. That is the runbook reporting something untrue, not a defect.
missing=()
for f in sse4_2 popcnt avx avx2 bmi1 bmi2 fma; do
grep -qw "$f" /proc/cpuinfo || missing+=("$f")
done
if (( ${#missing[@]} )); then
echo "vCPU lacks: ${missing[*]}"
echo "model: $(grep -m1 'model name' /proc/cpuinfo | cut -d: -f2- | sed 's/^ //')"
echo
echo "opencode is a Bun binary and needs AVX2. On this CPU it spins at 100%"
echo "forever instead of failing cleanly."
echo "Fix in the hypervisor: set the VM CPU type to 'host' (or x86-64-v3)"
echo "and reboot. No runbook can do this from inside the guest."
exit 1
fi
echo "vCPU supports the instruction sets the agent needs"
The agent
Self-contained binary from Arch's own repositories — depends only on curl,
glibc, icu, ripgrep and tar. No AUR, no npm, no install script piped
into a shell.
sudo pacman -S --needed --noconfirm opencode
pacman -Q opencode
pacman -Qi opencode >/dev/null || { echo "opencode not installed"; exit 1; }
command -v opencode >/dev/null || { echo "opencode not on PATH"; exit 1; }
echo "$(pacman -Q opencode) installed"
Secrets reach the agent through the environment, never the disk
An API key written into a config file is a plaintext secret on disk forever,
and this repo spent 015 avoiding exactly that. sops exec-env decrypts the env file straight into a child process's environment
and nowhere else — the mechanism SOPS provides for this, rather than something
improvised.
So the agent is launched through adm, never directly. adm agent runs
opencode with this box's secrets in its environment and nowhere else, and
adm fix is the same command under a second name — whichever reads better at
the prompt.
Bare words are a desired outcome, because that is what you almost always want:
adm fix "enable and configure a firewall allowing only ssh"
opens the agent already working on /adm enable and configure .... With no
words it opens an empty session. Raw opencode arguments go after --:
adm agent -- models
adm agent -- run --auto 'Reply with exactly: ok'
That split is explicit rather than inferred. Treating a leading opencode
subcommand as passthrough would turn adm agent run the backup script into
opencode run the backup script and start an agent on a mangled prompt
instead of failing — wrong in the shape that looks like it worked.
This was a separate script at /usr/local/bin/adm-agent until it became a
subcommand. Two reasons, neither urgent and both small: it now appears in
adm --help, which is where someone looks for it, and the launch rules stopped
being a generated shell script that had to re-quote its own arguments through
three layers of escaping. The rules did not change. They are still the part
worth knowing:
- Secrets reach the agent through
sops exec-env, into one child process, and are never written to disk. LLM_API_KEYis translated to the provider-specific name —LMSTUDIO_API_KEY, because the endpoint is reached through opencode's built-inlmstudioprovider — in exactly one place, so the env file stays provider-agnostic.--standaloneis required rather than preferred. By default opencode talks to a long-lived background service started without these secrets, so a key injected per invocation never reaches the process that needs it and every run fails with "No model is available". A private server inherits this environment, and the key is never held by a daemon.
sops and opencode are checked when those subcommands run, not when adm
starts. adm list and adm run therefore still work on a box where
000 has not installed sops yet — which is not a
hypothesis, it is the bootstrap path.
Use adm agent -n or adm fix -n to see the exact command either one would
exec, without execing it.
# Replay has to take the old wrapper away, not merely stop writing it. It
# lives outside /etc, so facts/ would never report it, and a box carrying an
# undescribed executable that still works is precisely the drift this repo
# exists to prevent.
sudo rm -f /usr/local/bin/adm-agent
command -v adm
# An absence assertion that can see what it asserts: confirm the directory is
# readable first, or "not there" cannot be told apart from "not allowed to
# look". Written as `if`, not `test ... && { exit 1; }`, because the latter
# leaves a non-zero status behind on the good path.
sudo test -d /usr/local/bin || { echo "/usr/local/bin is missing"; exit 1; }
if sudo test -e /usr/local/bin/adm-agent; then
echo "the old adm-agent wrapper is still installed; replay 070"
exit 1
fi
# Prove the injection mechanism without involving the agent: a value from the
# encrypted env must reach the child process.
got="$(sops exec-env "$ADM_BOX_DIR/$BOX_NAME.env" 'printenv BOX_NAME')"
test "$got" = "$BOX_NAME" || { echo "sops exec-env did not inject BOX_NAME"; exit 1; }
# And adm must actually be able to build the launch command.
cmd="$(adm agent -n)" || { echo "adm agent -n failed"; exit 1; }
grep -q -- "--standalone" <<<"$cmd" \
|| { echo "adm agent dropped --standalone; the key would not reach opencode"; exit 1; }
echo "wrapper removed; adm agent builds its command and sops injects for the child"
The key itself
Add it to the encrypted env file by hand — it is a credential, so no runbook generates it:
sops rea-dev/rea-dev.env # $EDITOR opens it decrypted, saves encrypted
Add the variable your provider expects (ANTHROPIC_API_KEY,
OPENAI_API_KEY, …) and a non-secret LLM_MODEL. It is encrypted because it
is not in .sops.yaml's unencrypted_regex, which is the correct default for
anything new.
Lock the agent to one provider
This is the most important block in the runbook, and it exists because of
something found the hard way. With no configuration and no credentials at
all, opencode answered a prompt successfully — which looked like a win and
was not. It had silently routed inference through its own hosted gateway:
outbound during a run: 104.20.32.17:443 -> api.opencode.ai / gateway.opencode.ai
172.65.90.21,.23 -> opencode.ai
opencode models: 10 models, every one opencode/*-free
Nothing asked for that. An agent working in /opt/admin reads this repo, so
on a default install this box sends prompts — and the contents of whatever
files the agent opens — to a third party that was never chosen. The facts/
snapshots alone describe the network layout.
So the configuration is default-deny, not merely "set a preferred model". Pinning a model while leaving other providers reachable is a preference; denying every provider but one is a control.
The four outbound paths, all closed
opencode's schema turns out to document each of these, and all four are set
explicitly rather than left at their defaults:
| key | value | why |
|---|---|---|
policies |
deny *, then allow openai |
Access control on provider.use. The deny-all is what makes the hosted gateway unreachable rather than merely unpreferred. Order is last-match-wins — the opposite of firewall convention. Written allow-first, as I did at first, it denies everything including the provider you just allowed, and opencode models silently returns nothing |
share |
disabled |
Sessions can otherwise be uploaded to opencode.ai for sharing. A session transcript of work in this repo is exactly what must not leave |
websearch |
false |
A search provider is another egress path, and another party receiving prompt text |
update |
disable |
No update checks. pacman owns this package; a self-updating binary would also drift out from under the runbook |
Note the schema marks enabled_providers, disabled_providers, provider
and autoupdate as deprecated — policies, providers and update are
the current spellings. Building on the deprecated keys would have worked today
and broken quietly later.
Which built-in provider to override, and why it is not openai
This took several attempts; the failures are recorded because none of them is hinted at by the schema.
A custom provider with package: "@ai-sdk/openai-compatible" parses
cleanly — opencode debug config shows it loaded, provider.updated fires,
no error appears at any log level — and then offers zero models. The same
shape with canonical: "openai" under a custom id behaves identically. Both
fail silently, which is the worst way to fail. Only overriding a provider
opencode already knows works.
The obvious choice, openai, works for exactly one turn and then breaks:
Error: 158 validation errors
'loc': ('body', 'input', ...), 'msg': "Input should be 'input_text'",
'input': 'output_text'
That is vLLM rejecting a request to /v1/responses. The giveaways are
EasyInputMessageParam and ResponseInputTextParam, and body.input — the
Responses API takes input, chat completions takes messages. On the second
turn opencode echoes the previous assistant message back as output_text
where vLLM's schema demands input_text, and the request dies. The first turn
has no assistant message to echo, so it succeeds — which is why this looked
like a working harness.
The provider's npm package decides the wire protocol, and models.dev records it per provider:
| provider | npm | protocol |
|---|---|---|
openai |
@ai-sdk/openai |
Responses API — incompatible here |
lmstudio |
@ai-sdk/openai-compatible |
chat completions — works |
deepseek, llama, ollama-cloud |
@ai-sdk/openai-compatible |
also fine |
So the override targets lmstudio, whose catalogue entry is literally a
self-hosted OpenAI-compatible server on a local URL — which is exactly what
vLLM is. Selecting it by protocol rather than by name is the point; the
name is incidental.
You are not running LM Studio. The id is a protocol selector, nothing
more: opencode has no vllm provider, and the ~190 providers sharing
@ai-sdk/openai-compatible are all named for commercial services — there is
no neutral "self-hosted" id to borrow. lmstudio is simply the one whose
catalogue entry describes a local OpenAI-compatible server, which is what
vLLM is.
Renaming it is not possible. A custom id with package set offers no models
at all; a custom id with canonical: "lmstudio" aliases to LM Studio
entirely — it returns LM Studio's own catalogue (gpt-oss-20b,
qwen3-30b-a3b) under an lmstudio/ prefix and ignores both our baseURL
and our models. So the borrowed id leaks into the model reference
(lmstudio/<model>) and there is no way around it.
What is adjustable is the display name, which the block below sets to name vLLM explicitly — so anywhere opencode shows the provider, it tells the truth about what it is talking to.
Check the npm field before choosing a different one:
curl -s https://models.dev/api.json | jq -r '.<provider> | {npm, api, env}'
Configuration comes from <box>.env
| key | required | what |
|---|---|---|
LLM_BASE_URL |
yes | the OpenAI-compatible endpoint, including /v1 |
LLM_MODEL |
yes | the model id the endpoint serves, as /v1/models reports it |
LLM_API_KEY |
yes | mapped to LMSTUDIO_API_KEY by adm agent; any value if the endpoint ignores keys |
The key is never written into the config file — adm agent injects it with
sops exec-env for one process. A key in opencode.json would be a plaintext
secret on disk, which is what 015 exists to prevent.
One confirmation, at the only channel that changes the box
Out of the box the agent asks for nothing. opencode debug agents reports the
default build agent resolving to a single rule — * * -> allow — so every
command runs the moment it is chosen. That is upstream's documented default
rather than a misconfiguration, but it makes a session impossible to follow:
the box is already changed by the time you have read why.
The obvious fix is to gate the dangerous verbs — sudo, pacman,
systemctl — and it is the wrong one. It prompts several times per task, each
prompt carrying too little context to judge on its own, and an operator who is
asked twelve times learns to approve without reading. A gate that is always
answered yes is decoration.
This repo makes a better gate available. Nothing changes this box except
through adm run — see AGENTS.md — so that one command is
the entire boundary between investigating and mutating. Gating it yields
exactly one approval per runbook execution, at the moment the plan is known
and before anything has happened, and the whole runbook runs under that single
yes however many blocks it contains.
So investigation is deliberately ungated and unlimited: reading files, grep,
systemctl status, adm list, adm verify, adm snapshots. sudo and
git push are gated as well, not as routine friction but as a tripwire — an
agent reaching for sudo directly is bypassing the runbook rule, and that is
worth an interruption rather than a note in the transcript afterwards.
adm undo and adm prune are gated for the obvious reason, and adm agent
and adm fix for a less obvious one: adm agent -- run --auto '...' starts a
headless agent, and headless does not consult this ruleset at all. Left
ungated, the gate could be stepped around by an agent simply launching another
one. Gating the launcher closes that, and the cost is a prompt on a command
nobody runs from inside a session anyway.
Which raises the maintenance problem this list has: every new mutating
subcommand needs a rule, and forgetting is silent. The agent-gate block below
therefore reads adm's own subcommand list and asserts that each one is either
gated or on an explicit read-only allowlist, so a subcommand added without a
rule fails the health check instead of being discovered in use.
Note what this does not rely on: plan mode. The built-in plan agent
denies edit and leaves bash alone. On a normal codebase that is read-only,
because changes arrive as file writes; here it is nearly meaningless, since
sudo pacman -S, systemctl and adm run all still execute in it — on this
box mutation travels through bash. The gate above holds in any agent, which is
why it is configured globally rather than per-agent.
What the gate does not cover, found by testing it rather than by reading.
The ruleset is consulted by the interactive TUI. It is not consulted by
opencode run. With bash * -> deny resolved — confirmed in
opencode debug agents — a headless run still executed sudo -n true and
reported its exit code. That is not a pattern mismatch: a blanket deny behaves
exactly like a narrow one, so headless runs do not consult the ruleset at all.
So the gate protects the operator's interactive session, which is how this box
is actually driven — see "Running it" below — and protects nothing whatsoever
in adm agent -- run. Treat a headless invocation as unrestricted, and do not
put one anywhere that a wrong command would matter.
That the TUI does stop and ask is the one claim here that no verify block can
make: it needs a terminal and a human to answer the prompt. It was
confirmed by hand on 2026-10-19, which is the only way it could be —
/adm install the unzip package in an interactive session prompted for
approval before anything had changed, and the change arrived as a runbook
block with its own verify rather than as a bare pacman call (commits
fedeb45, fdab3aa).
So the prompt is real. It is simply not a property adm verify can re-prove,
which makes this paragraph the evidence rather than the block below. Re-check
it by hand after any opencode upgrade: permission handling is exactly the
kind of behaviour a minor version can change silently, and nothing here would
notice.
The three read rules look redundant — opencode already asks before reading a
.env by default — and they are not. Rules merge with the built-in ruleset
appended to, and the catch-all * * -> allow above sits after the defaults,
so it silently re-permits the very .env reads the defaults protect. They
have to be restated below it to survive, *.env.example included, or
sops-encrypted configuration becomes readable without a prompt by accident.
This is the cost of last-match-wins: a catch-all is never only a catch-all.
The behavioural half — present a plan, ask once, then finish the job without
further check-ins — lives in skills/adm.md, written
further down this runbook. The permissions here are what make it more than a
polite request.
install -d -m 0755 "$HOME/.config/opencode"
: "${LLM_BASE_URL:?not set in $BOX_NAME.env -- refusing to leave the endpoint unpinned}"
: "${LLM_MODEL:?not set in $BOX_NAME.env -- refusing to leave the model unpinned}"
# Asserted even though some endpoints ignore it, because "no key needed" and
# "nobody set a key" are the same absence from here, and `adm agent` exports
# whatever it finds -- so an endpoint that does want one receives an empty
# string and answers 401 from somewhere much further away. Set it to any
# non-empty value if your endpoint does not check keys; that records the
# decision instead of leaving it to be inferred from a missing line.
#
# Being asserted is also what makes it visible: `adm adopt` and `adm env-fill`
# derive the keys a box needs from exactly these ${KEY:?} forms, and this one
# and FORGE_TOKEN were the two they could not see -- the two most secret
# values in the repo, which is to say the two env-fill exists for.
: "${LLM_API_KEY:?not set in $BOX_NAME.env -- set it to any value if this endpoint ignores keys, and add it with: adm env-fill}"
install -m 0644 /dev/stdin "$HOME/.config/opencode/opencode.json" <<EOF
{
"\$schema": "https://opencode.ai/config.json",
"providers": {
"lmstudio": {
"name": "vLLM self-hosted - id 'lmstudio' borrowed for its chat-completions protocol",
"settings": { "baseURL": "$LLM_BASE_URL" },
"models": { "$LLM_MODEL": { "name": "$LLM_MODEL" } }
}
},
"policies": [
{ "action": "provider.use", "resource": "*", "effect": "deny" },
{ "action": "provider.use", "resource": "lmstudio", "effect": "allow" }
],
"permissions": [
{ "action": "*", "resource": "*", "effect": "allow" },
{ "action": "bash", "resource": "adm run *", "effect": "ask" },
{ "action": "bash", "resource": "adm replay *", "effect": "ask" },
{ "action": "bash", "resource": "adm undo *", "effect": "ask" },
{ "action": "bash", "resource": "adm restore *","effect": "ask" },
{ "action": "bash", "resource": "adm prune *", "effect": "ask" },
{ "action": "bash", "resource": "adm publish *","effect": "ask" },
{ "action": "bash", "resource": "adm upstream *","effect": "ask" },
{ "action": "bash", "resource": "adm adopt *", "effect": "ask" },
{ "action": "bash", "resource": "adm enable *", "effect": "ask" },
{ "action": "bash", "resource": "adm disable *","effect": "ask" },
{ "action": "bash", "resource": "adm land *", "effect": "ask" },
{ "action": "bash", "resource": "adm env-fill *","effect": "ask" },
{ "action": "bash", "resource": "adm unlock *", "effect": "ask" },
{ "action": "bash", "resource": "adm issue *", "effect": "ask" },
{ "action": "bash", "resource": "adm reencrypt *","effect": "ask" },
{ "action": "bash", "resource": "adm agent *", "effect": "ask" },
{ "action": "bash", "resource": "adm fix *", "effect": "ask" },
{ "action": "bash", "resource": "sudo *", "effect": "ask" },
{ "action": "bash", "resource": "git push *", "effect": "ask" },
{ "action": "read", "resource": "*.env", "effect": "ask" },
{ "action": "read", "resource": "*.env.*", "effect": "ask" },
{ "action": "read", "resource": "*.env.example","effect": "allow" }
],
"model": "lmstudio/$LLM_MODEL",
"share": "disabled",
"websearch": false,
"update": "disable"
}
EOF
jq -c '{model, baseURL: .providers.lmstudio.settings.baseURL,
policies: (.policies|length),
gated: [.permissions[] | select(.effect=="ask") | .resource]}' \
"$HOME/.config/opencode/opencode.json"
f="$HOME/.config/opencode/opencode.json"
test -f "$f" || { echo "$f missing -- the endpoint is unpinned, see this runbook"; exit 1; }
jq -e . "$f" >/dev/null || { echo "$f is not valid JSON"; exit 1; }
test "$(jq -r '.model // empty' "$f")" = "lmstudio/$LLM_MODEL" \
|| { echo "pinned model is '$(jq -r .model "$f")', expected lmstudio/$LLM_MODEL"; exit 1; }
# Guard the protocol choice: @ai-sdk/openai speaks the Responses API, which
# vLLM rejects on the second turn. See this runbook.
jq -e '.providers | has("openai") | not' "$f" >/dev/null \
|| { echo "provider 'openai' is configured; it uses the Responses API and breaks multi-turn"; exit 1; }
# The base URL is the actual egress control: every request goes here.
test "$(jq -r '.providers.lmstudio.settings.baseURL // empty' "$f")" = "$LLM_BASE_URL" \
|| { echo "baseURL does not match LLM_BASE_URL"; exit 1; }
# Order matters and is counter-intuitive: LAST match wins, so the deny-all
# must come FIRST. Reversed, it denies everything including openai.
jq -e '[.policies[] | select(.action=="provider.use") | {r: .resource, e: .effect}]
== [{r:"*", e:"deny"}, {r:"lmstudio", e:"allow"}]' "$f" >/dev/null \
|| { echo "provider.use policies must be exactly [deny *, allow lmstudio], in that order:"
jq -c '[.policies[] | select(.action=="provider.use")]' "$f"; exit 1; }
test "$(jq -r '.share' "$f")" = "disabled" || { echo "share is not disabled"; exit 1; }
test "$(jq -r '.update' "$f")" = "disable" || { echo "update checks not disabled"; exit 1; }
test "$(jq -r '.websearch' "$f")" = "false" || { echo "websearch is not disabled"; exit 1; }
# No credential may be written into the config.
grep -qE '"(api[Kk]ey|apikey)"[[:space:]]*:' "$f" \
&& { echo "$f contains an apiKey field -- the key belongs in the env, not here"; exit 1; }
# The gate. Exactly these four, so a fifth added by hand shows up here as a
# decision nobody wrote down rather than as silent extra friction.
jq -e '[.permissions[] | select(.action=="bash") | "\(.resource) \(.effect)"]
== ["adm run * ask", "adm replay * ask", "adm undo * ask",
"adm restore * ask", "adm prune * ask", "adm publish * ask",
"adm upstream * ask", "adm adopt * ask", "adm enable * ask",
"adm disable * ask", "adm land * ask", "adm env-fill * ask",
"adm unlock * ask", "adm issue * ask",
"adm reencrypt * ask",
"adm agent * ask", "adm fix * ask",
"sudo * ask", "git push * ask"]' "$f" >/dev/null \
|| { echo "the bash gate list is not the expected set, in order:"
jq -c '[.permissions[] | select(.action=="bash")]' "$f"; exit 1; }
# Catch-all first: last match wins, same counter-intuitive order as policies.
# Reversed, every read-only command would prompt and the gate becomes noise.
test "$(jq -r '.permissions[0] | "\(.action) \(.resource) \(.effect)"' "$f")" = "* * allow" \
|| { echo "permissions[0] must be the catch-all allow; got: $(jq -c '.permissions[0]' "$f")"; exit 1; }
# Every mutating adm subcommand must have a gate. The list comes from adm
# itself, so one added without a rule fails here rather than being discovered
# in use. Asserted against the file, not the resolved ruleset: opencode's
# server caches the config, so resolution lags a rewrite and is the wrong place
# to check a list that changes whenever adm grows a subcommand.
#
# The allowlist is the judgement and is spelled out deliberately.
# list/verify/log/snapshots only read. facts writes, but only its own directory
# plus a commit of it, and it is meant to run at the end of every session, so
# gating it would train the operator to approve without reading -- the habit
# this gate exists to prevent. checkpoint is allowed for a different reason: it
# is a *safety* action, and requiring permission to be careful is backwards.
# The cost is that checkpoints never expire, so an agent could accumulate them;
# that is slow, visible in `adm snapshots`, and the operator's to prune.
# export writes one generated file under export/ and refuses to write at all if
# the result would contain a decrypted value, so the dangerous half of
# publishing a document is not here -- it is in sending it somewhere, which is
# `adm publish`, and that is gated -- it moves this machine's description off
# the machine. `adm issue` is gated for the same reason in miniature: the body
# is text this box composes and sends somewhere other people read.
readonly_ok="list verify log snapshots facts checkpoint export env-check"
# [a-z,-] rather than [a-z,]: a hyphenated subcommand (env-check) made the
# narrower pattern match nothing at all. It failed loudly thanks to the
# emptiness guard below, which is the only reason it was not a silent pass.
subs="$(adm --help 2>&1 | grep -o '{[a-z,-]*}' | head -1 | tr -d '{}' | tr ',' ' ')"
test -n "$subs" || { echo "could not read adm's subcommand list from --help"; exit 1; }
gated="$(jq -r '[.permissions[] | select(.action=="bash" and .effect=="ask")
| .resource] | join(" ")' "$f")"
for s in $subs; do
case " $readonly_ok " in *" $s "*) continue ;; esac
case " $gated " in *" adm $s * "*) continue ;; esac
echo "adm subcommand '$s' is not gated and is not on the read-only allowlist"
echo "either add a rule in this runbook or justify it in readonly_ok"
exit 1
done
echo "pinned to lmstudio/$LLM_MODEL at $LLM_BASE_URL; share/websearch/update off; no key in file"
echo "gated ($(wc -w <<<"$subs") subcommands checked): $(jq -r '[.permissions[]
| select(.effect=="ask" and .action=="bash") | .resource] | join(", ")' "$f")"
Prove the stock endpoints are gone
Before the lockdown, opencode models listed ten opencode/*-free models
served by the hosted gateway. The assertion is that no provider other than
the one we configured is reachable — not that the model list is short, since
the openai catalogue comes along for the ride.
test -f "$HOME/.config/opencode/opencode.json" \
|| { echo "no config yet; see opencode-config"; exit 1; }
models="$(timeout 120 adm agent -- models 2>&1 </dev/null || true)"
test -n "$models" || { echo "adm agent -- models produced nothing in 120s"; exit 1; }
# Any provider prefix other than openai/ means something else is reachable.
foreign="$(grep -E '^[a-z0-9_-]+/' <<<"$models" | grep -v '^lmstudio/' || true)"
if [[ -n "$foreign" ]]; then
echo "providers other than the configured one are reachable:"
printf '%s\n' "$foreign" | head -10 | sed 's/^/ /'
exit 1
fi
grep -qx "lmstudio/$LLM_MODEL" <<<"$models" \
|| { echo "configured model lmstudio/$LLM_MODEL is not offered"; exit 1; }
echo "only the configured provider is reachable; lmstudio/$LLM_MODEL present"
Prove the gate resolved, not merely that it was written
The config key for this was renamed: permission — singular, an object of
objects — is deprecated in favour of permissions, a plural ordered array of
{action, resource, effect}. Upstream's own permissions page still documents
the deprecated form, so the wrong one is the easy one to write. Writing it is
not an error. It is silently ignored, which would leave a config file that
reads as gated and an agent that asks for nothing — the failure this repo is
least able to detect by reading itself.
So this asserts what the binary resolved, through opencode debug agents,
rather than what the file contains. The file having a key is paperwork; the
agent having a rule is the property.
Resolution is as far as a verify block can reach. It proves the key was not silently ignored. It does not prove anything stops and asks — and, per the section above, in a headless run nothing does. Hence the wording of the message it prints: the block is deliberately not allowed to sound like proof of enforcement.
command -v opencode >/dev/null || { echo "opencode not installed"; exit 1; }
cd "$ADM_ROOT"
# `opencode debug agents` spawns a server to answer, so it can return nothing
# while another opencode is mid-start. That happens routinely right here:
# no-stock-providers and agent-smoke each start one just before this block, so
# a single attempt reported a policy failure that was not one. Retry instead,
# and keep stderr -- discarding it was why the first failure explained nothing.
out="$(mktemp)"; err="$(mktemp)"
got=""
for _ in 1 2 3; do
timeout 30 opencode debug agents >"$out" 2>"$err" || true
got="$(jq -r '.[] | select(.id=="build") | .permissions[] | "\(.action) \(.resource) \(.effect)"' \
"$out" 2>/dev/null || true)"
[[ -n "$got" ]] && break
sleep 2
done
if [[ -z "$got" ]]; then
echo "opencode debug agents reported no permissions for 'build' after 3 tries"
echo "stderr:"; sed 's/^/ /' "$err" | head -5
rm -f "$out" "$err"
exit 1
fi
rm -f "$out" "$err"
for want in "bash adm run * ask" "bash adm replay * ask" "bash sudo * ask" "bash git push * ask"; do
grep -qxF "$want" <<<"$got" \
|| { echo "the 'build' agent did not resolve the gate: $want"
echo "resolved ruleset was:"; printf '%s\n' "$got" | sed 's/^/ /'
echo "a deprecated 'permission' key is ignored in silence -- see this runbook"
exit 1; }
done
# Investigation must stay ungated, or the operator drowns in prompts and the
# single meaningful confirmation stops being read.
grep -qxF "* * allow" <<<"$got" \
|| { echo "no catch-all allow resolved; read-only work would prompt"; exit 1; }
# The catch-all must not have re-permitted .env reads. Order decides this, so
# assert the resolved tail rather than the mere presence of a rule.
test "$(grep -n 'read \*\.env ask' <<<"$got" | tail -1 | cut -d: -f1)" \
-gt "$(grep -n '^\* \* allow$' <<<"$got" | tail -1 | cut -d: -f1)" \
|| { echo "the catch-all allow outranks the .env guard; encrypted config is readable unprompted"
printf '%s\n' "$got" | sed 's/^/ /'; exit 1; }
# Only rules that do not change are asserted here. The list of gates lives in
# the file and is checked there, because opencode's background server caches
# the config: seconds after opencode-config rewrites it, `debug agents` can
# still report the previous ruleset. That would make this block flaky in the
# worse direction -- a false pass on a stale read -- and it already caught
# itself doing exactly that when `adm restore` was added.
echo "gate resolves: catch-all allow, adm run/replay, sudo, git push, .env guard"
echo "note: resolution only -- the TUI enforces this, 'opencode run' does not"
adm fix must not take the headless path
adm fix exists to save typing, and the cheap way to build it would have been
the non-interactive one: no terminal required, scriptable, obvious. That
implementation would be silently wrong. Headless runs do not consult the
permission ruleset at all, so a fix routed through opencode run would discard
the approval gate above while looking identical from the outside — the worst
shape of regression, because nothing fails.
So it opens the TUI with --prompt, which submits the slash command into an
interactive session and leaves the gate in place. The block below asserts the
command adm fix would actually exec, which is what -n is for; grepping
bin/adm would only establish that the right words appear somewhere in the
source.
cmd="$(adm fix -n 'enable a firewall' 2>&1)" \
|| { echo "adm fix -n failed: $cmd"; exit 1; }
grep -q -- "--prompt" <<<"$cmd" \
|| { echo "adm fix does not pass --prompt:"; echo " $cmd"; exit 1; }
grep -qF -- "/adm enable a firewall" <<<"$cmd" \
|| { echo "adm fix did not build the slash command:"; echo " $cmd"; exit 1; }
grep -q -- "--standalone" <<<"$cmd" \
|| { echo "adm fix lost --standalone:"; echo " $cmd"; exit 1; }
# The regression this block exists for: the headless subcommand in place of
# the interactive session. Written as `if`, so the good path leaves a zero.
if grep -qE "exec opencode run( |$)" <<<"$cmd"; then
echo "adm fix is routed through 'opencode run', which ignores permissions:"
echo " $cmd"
exit 1
fi
# An alias, not a similar command: identical output for identical input.
a="$(adm agent -n 'enable a firewall')"
b="$(adm fix -n 'enable a firewall')"
test "$a" = "$b" \
|| { echo "adm fix and adm agent disagree:"; echo " $a"; echo " $b"; exit 1; }
# And words after -- are opencode's arguments, not an outcome. If this ever
# starts producing a prompt, `adm agent -- models` is quietly asking an agent
# to think about the word "models" instead of listing them.
raw="$(adm agent -n -- models)"
if grep -q -- "--prompt" <<<"$raw"; then
echo "raw passthrough after -- was turned into a prompt:"; echo " $raw"; exit 1
fi
grep -qE "exec opencode models( |$)" <<<"$raw" \
|| { echo "adm agent -- models did not pass the subcommand through:"
echo " $raw"; exit 1; }
echo "adm fix == adm agent; outcomes use the TUI, -- passes opencode args through"
This repo's conventions, as a skill
The canonical text lives in skills/adm.md, written by
this runbook, and is installed into both agents' conventions so the same
instructions apply whether the work happens on the box or from a workstation:
.opencode/command/adm.md— invoked as/adm <outcome>inopencode.claude/skills/adm/SKILL.md— the same, for Claude Code
Two materialised copies from one source, for the same reason the git hooks work that way: a skill that exists only in one agent's format is a skill the other agent silently ignores.
cd "$ADM_ROOT"
install -d -m 0755 skills .opencode/command .opencode/commands .claude/skills/adm
install -m 0644 /dev/stdin skills/adm.md <<'SKILL'
Maintain this Arch box by changing the documentation, not the machine.
The user will describe a desired outcome. Deliver it by adding or amending a
runbook in this repository and running it — never by configuring the box
directly. A change made at a bare shell is invisible to this repo, and the
repo's entire value is that reading it tells you what the box is.
## Before anything
Read `AGENTS.md` and `README.md`. They are the contract, they are short, and
they contain rules that are not guessable — the fence syntax is local to this
repo, idempotence is mandatory and unchecked, and verify blocks have
non-obvious failure modes.
Investigate read-only first, and do it properly. Reading is ungated and
unlimited — files, `grep`, `systemctl status`, `journalctl`, `adm list`,
`adm verify` — so there is never a reason to guess at something you could
check, and no need to ask permission to look.
## Present a plan, then wait
The box changes only through `adm run`, and that command stops and asks. So a
task has exactly one moment that needs the operator, and it falls before any
of the work: the handoff from investigating to executing.
Before writing to a runbook or running one, show:
- **The outcome**, restated in your own words, so a misunderstanding surfaces
here instead of afterwards.
- **Which runbook** — amended or new — and for a new one, its number and why
it belongs there.
- **Each block** you intend to add, by `id`, one line on what it does.
- **What the verify block will assert** — the observable property, not the
fact that you ran the command.
- **What this touches** outside the repo: packages, units, files, services.
- **Anything you are unsure about**, named as such rather than smoothed over.
Then ask for a go-ahead and stop. Do not begin writing the runbook while
waiting for the answer.
## Ask once, then finish the job
Once the plan is approved, carry it through to the end without further
check-ins: write the runbook, run it, iterate on failures, verify, snapshot,
and land it.
The approval covers the landing. `adm land` files the issue, opens the pull
request and merges it in one gated command precisely so finishing does not
cost three more prompts -- raising them after the single approval is the
fatigue the gate exists to prevent.
## Secrets are not yours to carry
When a runbook needs a value you do not have -- a git remote, a host
fingerprint, an API key -- **stop and ask the operator to type it in
themselves**:
ssh -t <box> 'cd /opt/admin && adm env-fill'
Do not ask them to paste it to you, and do not offer to set it for them.
There is no flag that would let you: a value passed on a command line is in
your context, in your transcript, in shell history and in `/proc` for anyone
on the box. `adm env-fill` refuses to run without a terminal for exactly that
reason, and `getpass` reads the terminal directly, so a secret cannot be
piped in by anything.
What you may do is say *which* keys are missing and which runbook wants each
-- `adm env-check` and `adm adopt` both report that, and the names are not
secret. Then wait. When they say it is done, `adm env-check` will tell you
whether it is.
This is the one place the single approval does not carry you through. The
work stops until a person has typed something, and that is deliberate.
## Landing the work
Work on a branch and land it when it verifies:
git checkout -b <short-name>
...edit, adm run, iterate...
adm land -m "<what the operator asked for, in their terms>"
`adm land` verifies **before** publishing anything and merges only if that is
green. The pull request carries the verify output as evidence, the issue
records the request, and the commit messages carry the reasoning -- three
different questions, which is why there are three artifacts.
The `-m` text is the operator's request, not a summary of what you did. The
commits already say what you did.
### A commit message here is published
`adm upstream` copies these messages into the public repo as the pull
request's title and body, where there is no issue to click through to and no
access to this repository. So each message has to stand on its own.
State the problem first, in a sentence or two that assume nothing: what went
wrong, for whom, and how it showed. Then the implementation, briefly, and
only the parts a reader would otherwise wonder about. A message that opens
with "it was set globally" is a defence of a fix for something the reader has
not been told about -- correct and useless. That is a real example, not an
invented one: a message was rewritten for exactly this reason.
Write it for someone reading the public history in a year, who was not here.
If verify is red nothing is published, and the branch is the only record of
why the box changed. Fix it and land again, or revert the box with `adm undo`.
`adm land` will not undo for you: reverting a machine is the operator's
decision.
Something unrelated that you notice along the way is not a reason to stop.
Keep a note of it, finish what was approved, and file it with `/issue` before
you report back — not when you spot it, since `adm issue` is gated and that
would be an unplanned prompt in the middle of approved work.
Narrate as you go — which block is running, what came back — so the work can
be followed while it happens. Narrating is not asking. Stopping to confirm
each command is the failure this protocol exists to prevent: it teaches the
operator to approve without reading, which is worse than never asking.
Come back for a second confirmation only when:
- the plan was wrong, and the real fix is materially different from the one
approved;
- the work needs something no runbook can reach — a hypervisor change, a
forge credential, a secret;
- or a verify block fails in a way you cannot fix without widening the scope.
In those cases, say plainly what changed and re-present the plan briefly.
## Then, once the plan is approved
1. **Pick the runbook.** Amend an existing one if the change belongs to its
topic; otherwise create `runbooks/NNN-topic.md`. Filename order is
execution order, so a runbook may depend only on what is numbered below
it. Step by 10.
2. **Write why it exists before writing what it does.** The constraint, the
decision, the thing that will be non-obvious in six months, what you tried
that did not work. This is the part no other tool preserves, and the reason
this repo is prose rather than a script.
Write about *this box*. Not what the software is, not how it compares to
the alternatives — a reader can look that up, and it dates badly.
If there is no non-obvious reason, say so in one line and move on. A
package installed because something needed it needs no essay. Manufacturing
rationale to fill the section is worse than a short runbook: it reads as a
decision that was made when none was, and the next person has to work out
that there was nothing to understand.
3. **Write the `bash` block.** Idempotent, explicit `sudo` on privileged
commands, whole files from heredocs rather than appends. Per-box values
come from `<box>.env` via the environment — never hardcode a hostname, a
user, or a URL.
4. **Write a `verify` block** that asserts the property you actually care
about, read-only and fast, and that can *see* what it asserts — an
unprivileged check against a root-owned path can succeed for the wrong
reason. It must also be unable to fail for reasons unrelated to that
property: empty output from a probe means "could not observe", not
"absent", and a block that cries wolf gets ignored precisely when it
matters.
5. **Run it** with `adm run <runbook>` — this is where the single approval
prompt appears — and iterate until green. If a verify fails, prefer fixing
the runbook over loosening the check.
6. **Record state and commit:** `adm verify && adm facts`, then commit with a
message explaining *why*, and push.
## Rolling back
`adm run` brackets every invocation in a btrfs snapshot pair, so undoing one
runbook is `adm undo <pre>..<post>`; `adm snapshots` lists them and
`adm prune --empty` clears the pairs whose invocation changed nothing. Two
kinds of rollback request arrive, and they are not the same request.
**Undoing something from this session is routine.** You took the snapshot, you
know what is in the pair, and the runbook that caused it is in front of you. Do
it — and then deal with the runbook too: revert the edit, or say in its prose
why the change was abandoned. A reverted box sitting beside a runbook that
still describes the change is this repo turning into fiction, which is the one
failure it cannot absorb.
**Undoing something from outside this conversation needs care.** You cannot
know what happened in between: another session, another operator, an upgrade,
unrelated drift. So before proposing it, show what the pair contains, say
plainly what you cannot account for, and get a decision. Prefer the narrowest
undo that fixes the problem over reverting a whole pair. The fact that you can
name a snapshot number is not evidence that you understand what reverting it
will do.
**Verify after every rollback.** `adm undo` runs the verify pass itself and
will not pretend a failure is fine. Failures there are *expected*: a rollback
reverts the box behind its own documentation, and each failure names a runbook
that now describes something untrue. Resolve them by re-running that runbook or
by amending it. Never leave them failing and never weaken the check.
**Do not offer what does not exist.** `snapper rollback` cannot work here: the
root filesystem is the top-level subvolume, so there is no default subvolume to
swap for a snapshot. There is no "boot into the previous state" on this box,
only file-level undo. Snapper does not promise consistency afterwards either —
its own manual's example is undoing a user creation and leaving that user's
files behind. Package installs are the sharp case, since the files and the
pacman database can end up disagreeing with each other.
## When something cannot be done
Say so in the runbook, and let the verify block fail honestly. A check that
reports an untrue thing as fine is worse than no check. Hypervisor actions,
forge actions and anything needing a credential are legitimately outside a
runbook's reach — document the manual step rather than faking it.
## Do not
- Mutate the system outside a runbook.
- Revert the box without verifying afterwards, or treat the resulting failures
as noise.
- Begin the work before the plan has been approved.
- Ask for approval command by command. One plan, one confirmation.
- Weaken a verify block to make a run pass.
- Commit a secret. Values in `<box>.env` are SOPS-encrypted and the git hooks
will refuse a plaintext one; do not work around them.
- Leave a runbook describing a thing you did not actually run.
SKILL
# opencode: a slash command, with the user's request appended.
#
# Installed under BOTH command/ and commands/. v2's documentation specifies
# the plural directory; the singular one is what this repo wrote first and
# what earlier versions read. Which one this build honours could not be
# settled from outside: /api/command reports an empty list even for a command
# defined in opencode.json, so it is not a usable probe. An unregistered
# command fails as a slash command that merely does not exist, with nothing
# logged anywhere -- so install both.
#
# "Stop guessing" is what this used to say, and the guessing has since been
# done properly without settling it. opencode's current documentation names
# the plural -- .opencode/commands/ and ~/.config/opencode/commands/ -- but
# the version pinned above is v2.0.32, and which directory *it* reads cannot
# be established by inspection: the binary is 219 MB of bundled JavaScript,
# ".opencode" appears only as a bare literal joined at runtime, and neither
# "command" nor "commands" appears as a path constant anywhere near code that
# builds a path.
#
# So both stay, and the cost is three duplicated files. Removing the one that
# turns out to be live costs a slash command that silently does not exist,
# which is the failure this whole comment is about. Settling it needs a live
# test rather than a reading: delete one directory, start opencode, and type
# /adm. Worth doing before 1.0; not worth guessing from documentation written
# for a later version than the one installed.
cmd="$(mktemp)"
{
printf -- '---\ndescription: Maintain this Arch box by changing its runbooks, self-documenting the result\n---\n\n'
cat skills/adm.md
printf '\n## Requested outcome\n\n$ARGUMENTS\n'
} > "$cmd"
install -m 0644 "$cmd" .opencode/command/adm.md
install -m 0644 "$cmd" .opencode/commands/adm.md
rm -f "$cmd"
# Claude Code: a project skill
{
printf -- '---\nname: adm\ndescription: Maintain this Arch box by adding or amending a runbook in /opt/admin and running it, so the change documents itself. Use for any system maintenance, package, service or configuration task on this machine.\n---\n\n'
cat skills/adm.md
} > .claude/skills/adm/SKILL.md
chmod 0644 .claude/skills/adm/SKILL.md
wc -l skills/adm.md .opencode/command/adm.md .opencode/commands/adm.md \
.claude/skills/adm/SKILL.md
cd "$ADM_ROOT"
copies=(.opencode/command/adm.md .opencode/commands/adm.md .claude/skills/adm/SKILL.md)
for f in skills/adm.md "${copies[@]}"; do
test -s "$f" || { echo "$f missing or empty"; exit 1; }
done
for f in .opencode/command/adm.md .opencode/commands/adm.md; do
head -1 "$f" | grep -qx -- '---' || { echo "$f lacks frontmatter"; exit 1; }
grep -q '\$ARGUMENTS' "$f" || { echo "$f does not take the user's request"; exit 1; }
done
grep -q '^name: adm$' .claude/skills/adm/SKILL.md \
|| { echo "claude skill lacks a name"; exit 1; }
# Every installed copy must still match the canonical source -- including the
# plan protocol, which is the point of the skill and the easiest thing to lose
# by hand-editing one copy.
for f in "${copies[@]}"; do
for needle in 'Mutate the system outside a runbook' \
'Present a plan, then wait' \
'Ask once, then finish the job' \
'Rolling back'; do
grep -qF "$needle" "$f" \
|| { echo "$f is stale relative to skills/adm.md (missing: $needle)"; exit 1; }
done
done
echo "skill installed for opencode (command/ and commands/) and Claude Code"
/issue, for the thing you noticed but should not fix now
An agent in the middle of one task regularly notices another: a stale reference, a check that cannot fail, a runbook that would break on a rebuild. Fixing it immediately is how one approved plan turns into four unapproved ones. Forgetting it is worse.
So /issue files it and moves on. The mechanism is
adm issue rather than a curl in the prompt, for the same
reason /adm wraps adm run: building the request is code that can be
tested, while an agent escaping quotes and newlines into a JSON body works
until the first backtick. The agent's job is the part only it can do — a title
and a body still worth reading in six months.
adm issue derives the forge from $GIT_REMOTE rather than storing the
address again, the same single-source rule 020
applies to the host and port. Forgejo's API is GitHub-shaped, so the body and
the response fields are identical on both and only the base URL and
authorization scheme differ; $FORGE_API overrides the derivation for a
GitHub Enterprise /api/v3 or a Forgejo behind a subpath. It also refuses to
send a title or body containing a value that is encrypted in the env and
absent from the source — the same rule the export uses, for the same reason.
adm issue also comments on an issue (-c N) and closes one (-x N, with
-b as the closing comment). Those were missing for longer than they should
have been. The skill above tells the agent to file issues as it notices
things and adm land opens one per landing, so the repo accumulated a record
of outstanding work with no way to say any of it was done — and it got said
by hand-rolled POSTs instead, which is the curl in a prompt this section
argues against, and which skip the secret check entirely. In comments, which
is where failure output and key names go.
The refusal below is the other half, and the more important one. Every
positional is part of the title, so adm issue close 40 filed a new issue
titled "close 40": asking for a subcommand that did not exist created
something rather than refusing. A title that reads as an instruction about an
issue is refused now, and --force-title is there for the rare case where it
really is the title you want.
cd "$ADM_ROOT"
# Each of these must refuse. -n throughout, so a guard that fails to fire
# files nothing -- but it would still print, which is why stdout goes nowhere.
for bad in "close 40" "40" "comment 7" "reopen #12"; do
# shellcheck disable=SC2086
if adm issue -n $bad </dev/null >/dev/null 2>&1; then
echo "adm issue accepted '$bad' as the title of a new issue"; exit 1
fi
done
# </dev/null is not decoration. --force-title gets past the guard, so this
# reaches the body, and adm issue reads the body from stdin when -b is absent
# and stdin is not a tty -- which is every verify block. Without it this
# blocked on an inherited stdin for two and a half minutes inside adm land,
# looking exactly like a hang, because the verify runs with its output
# captured and so says nothing while it waits.
adm issue -n --force-title "close 40" -b '' </dev/null >/dev/null 2>&1 \
|| { echo "--force-title did not get past the guard"; exit 1; }
adm issue -n -x 40 a title here </dev/null >/dev/null 2>&1 \
&& { echo "a number and a title together was accepted"; exit 1; }
adm issue -n -c 40 </dev/null >/dev/null 2>&1 \
&& { echo "an empty comment was accepted"; exit 1; }
# A comment carrying a secret must refuse. This had no test on any path:
# _refuse_if_secret is what stands between an agent and a token in an issue,
# and nothing had ever asked it to fire.
#
# stdout is discarded for the whole pipeline deliberately. The body *is* the
# secret and a dry run prints the body, so if this guard ever stops firing
# the test has to fail without the value reaching a terminal or a run log.
# Reading stderr is safe because adm writes key names there, never values.
err="$(sops exec-env "$ADM_BOX_DIR/$BOX_NAME.env" \
'printf %s "$LLM_API_KEY"' | adm issue -n -c 1 2>&1 >/dev/null)" \
&& { echo "a comment containing an encrypted env value was accepted"; exit 1; }
grep -q 'refusing to send' <<<"$err" \
|| { echo "the comment was refused, but not by the secret check:"
sed -n 1p <<<"$err"; exit 1; }
echo "adm issue refuses: instruction-shaped titles, number with title, empty comment, secret in a comment"
The runbooks skill
adm list says what this box runs and what else it could; adm enable and
adm disable edit the opt-in list. The judgement left over is mapping a
request — "I want to be able to roll back a bad boot" — onto a runbook, and
that is what the skill is for.
The same split as /adm over adm run and /issue over adm issue: the
mechanism is code that was written once and can be tested, and only the
choosing is left to an agent.
cd "$ADM_ROOT"
install -d -m 0755 skills .opencode/command .opencode/commands \
.claude/skills/runbooks
install -m 0644 /dev/stdin skills/runbooks.md <<'SKILL'
Decide which optional runbook addresses what the operator asked for, and turn
it on.
Run `adm list` first. Every runbook is there with its state: required,
optional and enabled, or optional and not — the last with what it is for and
the command that enables it. That output is the catalogue; do not read the
runbooks directory to build your own.
## Enabling is not running
`adm enable <NNN>` changes the declaration. It does not touch the box. The
runbook's verify blocks start applying immediately, and whatever they assert
only becomes true once it has run:
adm enable 050
adm run 050
Report both or neither. Enabling something, announcing success and leaving a
red `adm verify` behind is half a job, and the half that looks finished.
Whether verify actually fails in between depends on the runbook. One whose
work is driven by a value — `045` does nothing while `MACHINE_USERS` is empty
— verifies clean the moment it is enabled. Do not promise a failure that may
not come; run `adm verify <NNN>` and say what happened.
## Disabling reverts nothing
This is the asymmetry to be explicit about. `adm disable 080` stops `080`'s
verify blocks from running. It does not uninstall `cmark-gfm`, does not remove
`export/`, does not undo anything. The state stays and stops being described,
which makes the repo *less true about the machine* — the only operation here
that does.
So treat "turn that off" as two questions, and ask the second one:
- Leave what it did, and accept that nothing now describes it.
- `adm undo`, if the snapshot pair from its run is still around.
- Write the runbook that removes it, if it should really be gone.
## Not in the middle of something else
Both commands change what the box will become, and both are gated. If the
operator asks for something and you notice a runbook that would help, say so
and finish the work you were asked to do. Enable it where the plan covers it,
not as a side effect of a different task.
## What you cannot do
`adm enable` refuses a required runbook and a number that does not exist, and
says which numbers are available. Take the refusal at face value rather than
editing `<box>.env` — hand-editing it is how this repo once made the whole
file undecryptable.
SKILL
cmd="$(mktemp)"
{
printf -- '---\ndescription: Choose and enable an optional runbook for this box\n---\n\n'
cat skills/runbooks.md
printf '\n## The request\n\n$ARGUMENTS\n'
} > "$cmd"
install -m 0644 "$cmd" .opencode/command/runbooks.md
install -m 0644 "$cmd" .opencode/commands/runbooks.md
rm -f "$cmd"
{
printf -- '---\nname: runbooks\ndescription: Choose and enable an optional runbook for this box, via adm list and adm enable. Use when the operator wants a capability the box does not have yet.\n---\n\n'
cat skills/runbooks.md
} > .claude/skills/runbooks/SKILL.md
chmod 0644 .claude/skills/runbooks/SKILL.md
wc -l skills/runbooks.md .opencode/commands/runbooks.md .claude/skills/runbooks/SKILL.md
cd "$ADM_ROOT"
for f in skills/runbooks.md .opencode/command/runbooks.md \
.opencode/commands/runbooks.md .claude/skills/runbooks/SKILL.md; do
test -s "$f" || { echo "$f missing or empty"; exit 1; }
done
# The copies are generated from one source; a hand edit to either would make
# the agent and the operator read different instructions.
# Everything after the closing --- of the frontmatter, with the blank line
# that follows it dropped. Counting lines instead made this depend on how
# many keys the frontmatter happens to have.
body() { awk 'n>=2{print} /^---$/{n++}' "$1" | sed '1{/^$/d}'; }
diff <(body .claude/skills/runbooks/SKILL.md) skills/runbooks.md >/dev/null \
|| { echo ".claude copy has drifted from skills/runbooks.md"; exit 1; }
# The three things the skill must not get wrong, asserted rather than trusted.
grep -q "does not touch the box" skills/runbooks.md \
|| { echo "the skill no longer says enabling is not running"; exit 1; }
grep -q "reverts nothing" skills/runbooks.md \
|| { echo "the skill no longer says disabling reverts nothing"; exit 1; }
adm enable 000 >/dev/null 2>&1 \
&& { echo "adm enable accepted a required runbook"; exit 1; }
echo "runbooks skill installed; adm enable refuses a required runbook"
Landing a change
adm land is the whole of #7's flow in one gated command: verify, file an
issue recording the request, push the branch, open a pull request carrying
the verify output, merge it, return to master.
One command because it is one decision. Four separate gated operations after
a single approval is the prompt fatigue the gate exists to prevent, which is
the same collision /issue hit and solved by deferral.
The merge is conditional on a green verify, and the verify runs first.
That ordering is the point: a pull request whose evidence was gathered after
the merge is not evidence. On a red verify nothing is published and the
branch is left as the only record of why the box changed — adm land does
not revert, because reverting a machine is the operator's decision and a
command that both changed the box and quietly changed it back is harder to
reason about than one that stops and explains.
A merge commit, not a squash. The commit messages here are the
documentation; squashing moves that narrative into the forge where git log
cannot see it. Upstream is the opposite case and squashes by necessity — see
020.
cd "$ADM_ROOT"
# In a throwaway clone, not here. An earlier version checked out branches in
# the live repo, which meant a verify could change which branch the operator
# -- or the `adm land` that invoked it -- was standing on. A check must not
# move the thing it is checking.
#
# It also recursed: `adm land` runs `adm verify`, so a refusal test that
# invokes `adm land` calls it again. On master the first refusal fired and
# hid that; run from inside a landing, where HEAD is a branch, it did not.
# `ADM_LANDING` now refuses re-entry outright, and this runs with it set as
# a second line of defence.
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
git clone --quiet --no-hardlinks . "$tmp/repo" 2>/dev/null \
|| { echo "could not clone the repo to test against"; exit 1; }
# The clone carries committed history; the runner under test is the one in
# the working tree, which is what every other verify checks and what is
# about to be committed. A clone also lands on whatever branch this repo is
# standing on, so master is selected explicitly.
# master first, then the overlay: copying the runner in dirties bin/adm, and
# git will not switch branches over a modified tracked file.
( cd "$tmp/repo" && git checkout -q master ) \
|| { echo "the clone has no master to test against"; exit 1; }
cp bin/adm "$tmp/repo/bin/adm"
cd "$tmp/repo"
export ADM_LANDING=1
adm() { timeout 60 "$tmp/repo/bin/adm" "$@"; }
out="$(adm land -m x 2>&1 || true)"
grep -q "already landing" <<<"$out" \
|| { echo "adm land did not refuse re-entry"; echo "$out"; exit 1; }
unset ADM_LANDING
out="$(adm land -m x 2>&1 || true)"
grep -q "works on a branch" <<<"$out" \
|| { echo "adm land did not refuse to run on master"; echo "$out"; exit 1; }
git checkout -q -b probe
if adm land -m x >/dev/null 2>&1; then
echo "adm land published from a branch with no commits on it"; exit 1
fi
printf 'probe\n' > land-probe.txt
git add land-probe.txt
out="$(adm land -m x 2>&1 || true)"
grep -q "commit the work first" <<<"$out" \
|| { echo "adm land did not refuse an uncommitted tree"; echo "$out"; exit 1; }
echo "adm land refuses re-entry, master, a branch with no commits, and uncommitted work"
cd "$ADM_ROOT"
install -d -m 0755 skills .opencode/command .opencode/commands \
.claude/skills/issue
install -m 0644 /dev/stdin skills/issue.md <<'SKILL'
Record a bug or a piece of future work as an issue on this repo's forge.
Use this for something worth doing that is **not** the task in hand. The point
is to stop losing it without starting it: one approved plan should not quietly
become four unapproved ones.
## When — not in the middle
Note it as you go and file it **after** the approved work is finished, before
you report back. `adm issue` is gated, so filing one mid-task puts an approval
prompt in front of the operator that was not in the plan they approved — and
being interrupted by unplanned prompts is the exact habit the protocol in the
`adm` skill exists to prevent. One task, one approval, then the issues.
If you already know while planning that you will file one, say so in the plan.
Then it is part of what was approved and costs nothing extra.
There is no queue in the harness for this. Deferring is a decision you make,
not a mechanism that makes it for you.
## Write it for someone who has forgotten everything
That someone is usually you, in six months. A title that names the thing, and
a body with:
- **What was observed**, concretely. The command, the output, the file and
line. "Verify is flaky" is a feeling; "agent-gate failed twice and passed on
retry, because `opencode debug agents` returns nothing while another
opencode is starting" is a bug report.
- **Why it matters**, which is the part that decides whether anyone picks it
up. What breaks, for whom, and when — on the next replay, on a rebuild, on a
box without a hypervisor.
- **What you would check first**, if you already know. A guess, named as one,
is worth more than nothing and costs the reader nothing to discard.
- **What you could not determine**, said plainly rather than smoothed over.
Keep the runbook conventions in mind: this repo values the reason over the
change, and an issue is mostly reason.
## Then file it
adm issue "<title>" -b "<body>"
Or pipe the body in, which is easier for anything multi-line:
adm issue "<title>" <<'EOF'
...
EOF
Add `-n` to see exactly what would be sent without sending it.
## Do not
- Fix the thing instead of filing it, unless the operator asked you to.
- File it mid-task and make the operator answer a prompt they did not plan for.
- File a duplicate without saying so. Read the open issues first.
- Put a secret in a title or body. `adm issue` refuses values that are
encrypted in `<box>.env` and absent from the source, but that check is a
backstop and not a licence.
- File an issue to record something a runbook should say. If the knowledge
belongs in the repo, write it there instead.
SKILL
cmd="$(mktemp)"
{
printf -- '---\ndescription: File a bug or a piece of future work as an issue on the forge\n---\n\n'
cat skills/issue.md
printf '\n## What to record\n\n$ARGUMENTS\n'
} > "$cmd"
install -m 0644 "$cmd" .opencode/command/issue.md
install -m 0644 "$cmd" .opencode/commands/issue.md
rm -f "$cmd"
{
printf -- '---\nname: issue\ndescription: File a bug or future work as an issue on this repo'"'"'s forge, via adm issue. Use when something worth doing is not the task in hand.\n---\n\n'
cat skills/issue.md
} > .claude/skills/issue/SKILL.md
chmod 0644 .claude/skills/issue/SKILL.md
wc -l skills/issue.md .opencode/commands/issue.md .claude/skills/issue/SKILL.md
cd "$ADM_ROOT"
for f in skills/issue.md .opencode/command/issue.md \
.opencode/commands/issue.md .claude/skills/issue/SKILL.md; do
test -s "$f" || { echo "$f missing or empty"; exit 1; }
grep -qF 'adm issue' "$f" \
|| { echo "$f does not tell the agent how to file one"; exit 1; }
done
grep -q '\$ARGUMENTS' .opencode/commands/issue.md \
|| { echo "the opencode command takes no argument"; exit 1; }
grep -q '^name: issue$' .claude/skills/issue/SKILL.md \
|| { echo "the claude skill lacks a name"; exit 1; }
# The plumbing, without sending anything. Derivation from GIT_REMOTE is the
# part most likely to rot: a forge rename changes the remote and this has to
# follow it rather than keeping a second copy of the address.
out="$(adm issue "plumbing check" -b "body" -n)" \
|| { echo "adm issue -n failed"; exit 1; }
grep -q 'would POST to: https://.*/repos/.*/issues' <<<"$out" \
|| { echo "adm issue did not derive an issues endpoint:"; echo "$out"; exit 1; }
tok="$(sops -d --input-type dotenv --output-type dotenv \
"$ADM_BOX_DIR/$BOX_NAME.env" \
| awk -F= '$1=="FORGE_TOKEN" {print substr($0, 13)}')"
if [[ -n "${tok// /}" ]] && grep -qF -- "$tok" <<<"$out"; then
echo "adm issue -n printed the forge token"
exit 1
fi
echo "issue skill installed; adm issue derives $(sed -n 's/.*would POST to: //p' <<<"$out")"
Smoke test — two turns, not one
The blocks above assert the harness is installed. This asserts it works, and it runs two turns deliberately.
A single-turn test cannot detect the failure this runbook exists to document.
The openai provider passed a one-turn smoke test while being fundamentally
broken: the bug lives in how prior assistant messages are serialised back into
the next request, so turn one — which has no history to serialise — always
succeeds. The harness was reported working, and failed on the user's second
prompt.
Any conversational test that never builds history is testing the easy half.
Both runs pass --auto. Today that changes nothing, because headless runs do
not consult the permission ruleset at all — see the gate section above. It is
there so this block stays correct if that ever changes: a verify block runs
with stdin closed, and the day opencode run begins honouring ask is the
day an unflagged health check starts hanging until its timeout instead of
reporting. These runs exercise the wire protocol, not the gate.
command -v opencode >/dev/null || { echo "opencode not installed"; exit 1; }
cfg="$HOME/.config/opencode/opencode.json"
# Refuse to exercise an unpinned agent: without this the test can pass by
# using a default gateway, certifying a configuration nobody chose.
test -f "$cfg" && jq -e '.model // empty' "$cfg" >/dev/null 2>&1 \
|| { echo "no provider pinned; not running the agent (see opencode-config)"; exit 1; }
one="$(timeout 150 adm agent -- run --auto 'Reply with exactly: harness ok' 2>&1 </dev/null || true)"
if [[ -z "$one" ]]; then
echo "no output in 150s -- the usual cause is a vCPU without AVX2"
echo "see the agent-cpu-support block in this runbook"
exit 1
fi
grep -qi "harness ok" <<<"$one" \
|| { echo "turn 1 did not answer as asked:"; printf '%s\n' "$one" | tail -5; exit 1; }
# Turn 2 replays turn 1's assistant message back to the endpoint. This is the
# turn that fails when the provider speaks the wrong protocol.
two="$(timeout 180 adm agent -- run --auto 'Reply with exactly: still ok' --continue 2>&1 </dev/null || true)"
grep -qiE "validation error|input_text|output_text" <<<"$two" && {
echo "turn 2 was rejected by the endpoint -- wrong wire protocol?"
printf '%s\n' "$two" | head -4
echo "see 'Which built-in provider to override' in this runbook"
exit 1; }
grep -qi "still ok" <<<"$two" \
|| { echo "turn 2 did not answer as asked:"; printf '%s\n' "$two" | tail -5; exit 1; }
echo "agent answered two turns using $(jq -r .model "$cfg"); conversation history round-trips"
Running it
tmux new -s agent # detachable: an agent run outliving your SSH session
adm agent # interactive, secrets injected
adm fix 'enable a firewall allowing only ssh' # or go straight to work
/adm enable and configure a firewall allowing only ssh
Expect one prompt per task, when the agent reaches adm run; approving it
approves that whole runbook. Being asked more often than that is a signal, not
a setting: something is mutating the box outside adm run, which is a bug in
the runbook being written rather than in the gate. /adm is also where the
agent is told to show its plan first — see skills/adm.md.
tmux is not decoration here — it is why 060
installs it. A long agent run tied to an SSH connection dies with the
connection.
080 — The rendered document
Optional — a box opts in by adding 080 to OPTIONAL_RUNBOOKS in its <box>.env.
Render the whole repo as one self-contained page and publish it behind a private link, so someone who will never log into this box can read what it is -- and rebuild it from the page if the box is gone.
AGENTS.md requires every runbook to be generic, with anything
true of one box rather than of the procedure coming from <box>.env. That is
the right rule and it has a cost: reading runbooks/ means reading
$GIT_REMOTE and $ADMIN_USER and holding the substitutions in your head.
The export is where that cost is paid back — generic source, concrete render.
What goes in, and what is held back
Every value the export substitutes is one that is already plaintext in
<box>.env, and every value it redacts is one that was encrypted. The
redaction set is not a list maintained here; it is derived from
.sops.yaml's unencrypted_regex, which is the repo's existing and enforced
statement of what is secret. 015 encrypts anything new by
default, so a key added tomorrow is redacted tomorrow without anyone
remembering to add it anywhere.
A hand-kept blacklist was the obvious alternative and is a worse idea. It
would be a second source of truth about what is secret, and its failure mode
is the bad one: add a secret to the env, forget the blacklist, render it into
a file, publish the file. This repo has already produced that exact shape of
bug three times — a duplicated check_env, duplicated filter patterns, and an
embedded SOPS policy that silently disagreed with .sops.yaml — and the third
was live and wrong for several commits.
Derivation is still not enough on its own, because it only governs substitution. Nothing stops a runbook from printing a secret in its own output. So after rendering, the exporter searches the result for every decrypted value and refuses to write the file if one appears. That makes "secrets never reach the export" a checked property rather than an intention.
Why one file
A single self-contained document, assets inlined. One object to publish, one
pre-signed URL to read it with, and every cross-reference is an #anchor
rather than a link to another object. A private multi-page site on
object storage needs either a public bucket or an authenticating proxy in
front, because pre-signed URLs are per-object and relative links between pages
cannot carry a signature — click through and the second page is a 403.
The renderer
cmark-gfm: the CommonMark reference implementation with GitHub's extensions,
which is what gets the tables in README.md and
DECISIONS.md rendered. A 400 KiB C binary from the official
repositories with no runtime dependencies beyond libc — in keeping with
060's refusal of anything needing npm.
adm does not hand whole files to it. CommonMark renders a fenced block's
info string as class="language-bash" and discards the rest, which would
throw away id= and verify — precisely the attributes that separate a
command this box actually ran from an illustration. adm already parses
runbooks into blocks, so prose goes through cmark-gfm and blocks are
rendered by adm itself, labelled with what they are.
sudo pacman -S --needed --noconfirm cmark-gfm
cmark-gfm --version | head -1
command -v cmark-gfm >/dev/null || { echo "cmark-gfm not on PATH"; exit 1; }
# Tables are a GFM extension rather than core CommonMark, and this export
# needs them, so assert the extension is present rather than just the binary.
printf '| a | b |\n| - | - |\n| 1 | 2 |\n' \
| cmark-gfm -e table | grep -q '<table>' \
|| { echo "cmark-gfm cannot render tables; is the table extension missing?"
exit 1; }
echo "cmark-gfm $(cmark-gfm --version | head -1 | awk '{print $2}') renders GFM tables"
Rendering it
adm export # -> export/<box>.html, gitignored
adm export -o /tmp/x.html
export/ is gitignored. The document is generated, a third of a megabyte, and
would churn on every commit; the repo already contains everything it is made
from. It is published rather than committed — see the publish section when it
exists.
Not included: hosts/<box>/facts/ and hosts/<box>/log/. The export renders the
intent — the prose and the commands — rather than the machine's recorded
state, and facts/ holds internal addresses and listening sockets that have
no business in a document whose whole purpose is being easy to pass around.
cd "$ADM_ROOT"
out="$(mktemp --suffix=.html)"
err="$(mktemp)"
adm export -o "$out" >/dev/null 2>"$err" \
|| { echo "adm export failed"; cat "$err"; rm -f "$out" "$err"; exit 1; }
# The highlighter checks every block it colours by stripping the markup back
# off and comparing with what it was given; on any mismatch it emits the block
# plain and says so. These are commands someone may paste into a root shell,
# so a highlighter that drops a quote is worse than none -- that check is the
# reason this is safe, and this asserts it never had to fire.
if grep -q 'highlighter' "$err"; then
echo "the highlighter could not colour some block without altering it:"
grep 'highlighter' "$err" | head -3
rm -f "$out" "$err"; exit 1
fi
rm -f "$err"
# And that it is doing anything at all. A highlighter that silently stopped
# matching would leave a correct, entirely grey document.
# The copy buttons exist only as script, so there is no markup to look for.
# Assert the script that builds them is still in the page: losing it would
# leave a document that looks entirely correct and silently cannot be copied.
for want in codewrap clipboard execCommand textContent; do
grep -qF -- "$want" "$out" \
|| { echo "the copy-button script is missing \"$want\""; rm -f "$out"; exit 1; }
done
for cls in 'class="cmd"' 'class="s"' 'class="c"' 'class="v"'; do
grep -qF -- "$cls" "$out" \
|| { echo "no $cls spans in the export; highlighting is not running"; \
rm -f "$out"; exit 1; }
done
# Structure, so a renderer that silently stopped emitting tables or blocks is
# caught rather than producing a plausible-looking shorter document.
for want in '<table>' 'class="blk run"' 'class="blk verify"' '<section id='; do
grep -qF -- "$want" "$out" \
|| { echo "export is missing $want"; rm -f "$out"; exit 1; }
done
# The property that matters. adm export already refuses to write a file
# containing a decrypted value, so this asserts the same thing independently:
# if the gate inside adm ever stopped working, this would still fail.
leaked=0
while IFS='=' read -r k v; do
case "$k" in ''|'#'*|sops_*) continue ;; esac
case "$v" in 'ENC['*) : ;; *) continue ;; esac # encrypted keys only
val="$(sops -d --input-type dotenv --output-type dotenv \
"$ADM_BOX_DIR/$BOX_NAME.env" | awk -F= -v k="$k" '$1==k {print substr($0, length(k)+2)}')"
[[ -z "${val// /}" ]] && continue
if grep -qF -- "$val" "$out"; then
echo "the export reproduces the decrypted value of $k"
leaked=1
fi
done < "$ADM_BOX_DIR/$BOX_NAME.env"
# Every heading links to itself, and to *itself* rather than to whichever
# anchor happened to be nearby -- a self-link pointing at the wrong id is
# worse than none, because it copies cleanly and lands somewhere else.
heads="$(grep -co '<h[1-6] id=' "$out")"
mismatched="$(python3 - "$out" <<'PY'
import re, sys
s = open(sys.argv[1], encoding="utf-8").read()
bad = 0
for tag, hid, inner in re.findall(r'<(h[1-6]) id="([^"]+)">(.*?)</\1>', s, re.S):
if ('href="#%s"' % hid) not in inner:
bad += 1
print(bad)
PY
)"
test "$mismatched" = 0 \
|| { echo "$mismatched heading(s) do not link to their own id"; rm -f "$out"; exit 1; }
# No anchor anywhere in the document may point at an id that is not in it.
dead="$(python3 - "$out" <<'PY'
import re, sys
s = open(sys.argv[1], encoding="utf-8").read()
ids = set(re.findall(r'\sid="([^"]+)"', s))
print(len({h for h in re.findall(r'href="#([^"]+)"', s) if h not in ids}))
PY
)"
test "$dead" = 0 \
|| { echo "$dead dead anchor(s) in the export"; rm -f "$out"; exit 1; }
# Self-containment, which is the property the whole single-file design rests
# on: a pre-signed URL authorises one object, so anything the page fetches
# from elsewhere is a request that cannot be signed and will not load. An
# earlier version of this asserted the page carried no script at all, which
# was the wrong invariant -- two behaviours genuinely depend on when the page
# is read -- and it is the *external reference* that breaks the design, not
# the script tag.
if grep -qE '<script[^>]+src=|<link[^>]+stylesheet|<img[^>]+src="https?:' "$out"; then
echo "the export references something it would have to fetch:"
grep -oE '<script[^>]+src=[^ >]*|<link[^>]+stylesheet[^>]*|<img[^>]+src="https?:[^"]*' "$out" | head -5
rm -f "$out"; exit 1
fi
sections="$(grep -co '<section id=' "$out")"
size="$(( $(stat -c %s "$out") / 1024 ))"
rm -f "$out"
test "$leaked" = 0 || exit 1
echo "export renders $sections sections, $heads self-linking headings, no dead anchors, ${size} KiB, no decrypted value in it"
The page is also the repository
The document carries its own sources: every markdown file it was rendered
from, in <template> elements the browser does not display, plus bin/adm
itself in an appendix. With those, the page can be mined back into a working
repository — everything except the git history, which only the bundle has.
<template> rather than a <script> block, because its contents are parsed
as HTML: entities decode normally for anything reading it in a browser, and a
plain-text extractor can unescape the same bytes without one. A <script>
would have neither property and would break outright on a literal </script>
in the text.
Why the rendered page cannot be mined instead
This is the part worth being clear about, because it looks like duplication
for nothing. adm parses markdown fences, and the blocks you can read on the
page have already had this machine's environment substituted into them —
/opt/admin where the source says $ADM_ROOT, rea-dev where it says
$BOX_NAME. A runbook reconstructed from the visible text would be specific
to this box, which is the one thing AGENTS.md forbids. The
templates hold the generic originals; the rendered page holds what actually
ran. Both are wanted, and they are not the same bytes.
It costs about 300 KB, taking the document past a megabyte. That is the price of an artifact that can be printed, mailed or carried on a stick and still turn back into the thing it describes.
The document knows its own address
The rebuild directive is meant to be pasted with nothing to edit, which means
the page has to carry a working link to itself. That requires signing
index.html before the render that quotes it is uploaded — which works only
because a signature covers the key and not the content, the same property that
lets one link keep returning the current document.
rclone will not sign a key that does not exist, so the order is: sign the object still sitting there from last time, render the new document around that link, then replace the object. The signature stays valid across the replacement. The very first publish of a box has nothing to sign, so the directive falls back to telling the reader to supply the file themselves.
The link that gets printed is the same one embedded in the page. Minting a second would be two answers to one question, and they would expire at different moments.
What it does not carry
Everything that belongs to a particular machine: its <box>.env, its age
private key, its facts, its AS-FOUND.md. Those are absent by design, not by
omission — the first two must never be published, and the last two are records
of this box rather than instructions for any box. What comes out of the
unpacker is the generic half: runbooks, conventions, decisions, and the runner.
000 is where a machine of your own begins.
Asserting it rather than claiming it
export-round-trips renders the document, pulls every embedded source and the
runner back out, and compares each with the file it came from — then checks
that the sha256 printed beside the runner is the one a reader would actually
compute. An escaping bug anywhere on that path would otherwise surface as a
repository that looks right and does not run, which is the worst way to find
out. It catches a missing trailing newline.
Colouring the commands
Highlighting happens at render time, in adm, with no library. Partly because
a client-side highlighter would be a dependency to vendor into a file that has
to stay self-contained, and partly for the same reason everything else here is
decided when the page is written: the language of a block does not depend on
who is reading it.
It is a highlighter, not a parser, and it works one line at a time. That
is the decision that matters. Shell is full of things a line scanner gets
wrong — an awk program quoted across three lines, a heredoc holding
something that is not shell — and the failure mode of a stateful scanner is
that one stray quote paints everything after it as a string, for the rest of
the block. Per line, any such mistake stops at the end of that line.
The contextual part is knowing where a command can start: the beginning of a
line, and after |, &&, ;, (, do, then. That is what makes the
first word of a pipeline read as a command and the rest as arguments. Words
that run other commands — sudo, env, xargs, timeout — pass that
position along, so sudo pacman -S colours both rather than making pacman
look like an argument to sudo.
The check that makes it safe
These are commands someone may paste into a root shell, so a highlighter that
drops a quote is worse than no highlighter. Every block is therefore stripped
back to plain text and compared with what went in, on every render. On any
mismatch the block is emitted plain and adm says so on stderr — the failure
is safe by construction rather than by inspection, and export-renders
asserts the check never had to fire.
What it declines to guess
Executable blocks are known to be shell: parse_runbook only returns bash
blocks with an id=. Illustrative fences carry no language, so they are
sniffed, and the sniff is deliberately timid — a wrong guess colours the wrong
words and leaves the reader working out which parts to disbelieve. JSON is
detected by parsing it, which is exact. A block whose first line reads
name: value is left alone: in this repo those are a .sops.yaml fragment, a
setfacl error and an opencode validation failure, and none is improved by
pretending to understand it. Everything else is treated as shell.
The markup costs about 170 KiB on a 400 KiB document — spans around roughly five thousand tokens. Still one file, still no request.
Copying one
Every block gets a copy button, built by the page's script rather than rendered into it. That sounds backwards given everything above, and it is the same rule: a button that copies needs a clipboard, so one rendered into the HTML would be a dead control for a reader without JavaScript. Built at read time, it exists only where it can work — and it keeps about 17 KB of identical markup out of a document with 177 code blocks.
It copies textContent, not innerText, because the block is preformatted
and innerText would normalise away the whitespace that makes it a runnable
command. What you get is exactly what is on screen, including any
‹redacted› marker, which is the honest thing to hand over: the command as
this box ran it, with the secret still missing.
The button says Copied in green for a moment and goes back to Copy. If the clipboard is unavailable it says Press ctrl-C in red instead. That case is real rather than theoretical: the async clipboard API needs a secure context, which the published document has over https and the same file opened from disk may not — which is how anyone reviewing an export before sending it is reading it. There is a fallback for that, and if the fallback fails too the button says so rather than quietly looking like it worked.
Finding your way around
The document is long, so the contents are always reachable — in two forms, chosen by width.
Wide enough and there is a tray down the right-hand side, always open. The
page itself does not scroll at all: html is overflow: hidden and the two
columns each scroll on their own, so the document's scrollbar sits at the
right edge of the text it scrolls and the tray's at the right edge of the
window. A sticky tray inside a scrolling page puts the two scrollbars
side by side, which reads as one broken double-width bar.
Narrow and the tray is gone, replaced by a book button fixed in the top-right corner. It was an inline collapsible box before, which is something you scroll past once and then cannot find again. Fixed means always where you left it. Picking an entry closes the panel, because it is laid over the page and would otherwise cover the heading it just jumped to.
Every heading is also a link to itself, so getting a link to a section means right-clicking its title rather than hunting for it in the contents. The id lives on the heading, not the anchor, so every cross-reference written for the forge still resolves here.
Publishing it
The rendered document goes to S3-compatible object storage — DigitalOcean Spaces here — as one private object, read through a pre-signed URL.
That shape follows from wanting it private. A pre-signed URL authorises one
object, and relative links between pages cannot carry a signature, so a
private multi-page site needs either a public bucket or an authenticating
proxy in front of the CDN. One self-contained file sidesteps both: every
cross-reference is an #anchor, one signature covers the whole document, and
"replace the previous publish" is overwriting a single key rather than a sync
that has to work out which objects are now orphans. The orphan problem is not
solved here, it is absent.
The second artifact: the repo itself
The document says what this machine is. It does not let you rebuild it. So
adm publish uploads a second object beside it — <box>.bundle, a
git bundle --all, which is the whole repository as one file:
rclone copyto pub:<bucket>/<box>/<box>.bundle ./rea-dev.bundle
git clone rea-dev.bundle rea-dev
Every commit, every runbook, and the encrypted env file, from an object store that has never heard of git. That is the artifact you want when the machine is gone, the forge is unreachable, or both — the case where a document describing the machine is interesting and a repo you can clone is useful.
It is never signed. The document is the artifact that travels, and a
pre-signed link travels with it: forward the link, forward everything it
reaches. The document carries ciphertext and public keys; the bundle carries
this machine's facts/, AS-FOUND.md and run journal in the clear. Those
are not the same thing to hand a stranger, so only one of them is reachable
by link, and the bundle is fetched with the credentials adm publish already
uses. The document names the object and states the commit it was itself built
from, so an operator holding both can tell which is older — the two are
replaced on every publish and nothing makes a reader re-open the page.
A bundle carries commits and nothing else, so it stops at HEAD while the
document renders the working tree. When those disagree, publish says so rather
than letting a clone silently lag what someone just read.
Why there is no sha sidecar
The obvious way to know whether the bundle needs re-uploading is to store the
HEAD sha beside it and compare. It is unnecessary: for a single-part upload
an object's ETag is its MD5, so the content hash is already stored with
the object, and rclone --checksum compares against it. The server is asked
whether these bytes are already there, which is a question it can answer
exactly.
A sidecar would be a second record of the same fact, with the failure mode that implies — the object lands and the sha does not, and now the box believes something false about the bucket. The sha is still recorded, in the document, as the commit it reflects. That is provenance, which is a reader's question, not a transfer decision.
This is also why --checksum is not optional. rclone's default comparison is
size plus modtime, and publish rebuilds both artifacts every run, so the mtime
always differs even when the bytes are identical — every publish re-uploaded
everything. And it is why the bundle is built with pack.threads=1: threaded
delta compression picks different deltas from run to run, so an unpinned
bundle of identical history hashes differently each time and would be
re-uploaded forever.
What a pre-signed URL actually grants
Both of these were tested against Spaces rather than reasoned about, because the answers decide how links may be handed out.
Replacing the object does not invalidate the link. The signature covers
the method, the key and the expiry — not the content, and not a version. The
same URL fetched version one, the object was overwritten, and the same URL
then returned version two. So a link is a subscription to that key for its
whole window: --expire 7d gives someone a week of always-current reading,
and every publish silently updates what they already have open.
That cuts both ways. A link cannot be withdrawn once given — there is no
revoking a signature. Shortening the window is the only control, and the
floor on it is how long you are willing for the holder to keep reading.
Rotating PUBLISH_S3_ACCESS_KEY invalidates every outstanding link at once,
which is the blunt instrument available if one escapes.
The default is --expire 7d, which is also the maximum: Spaces allows no
longer, and asking for more gets silently clamped rather than refused — 8d
and 30d both come back as X-Amz-Expires=604800. rclone mentions the
clamp on stderr, so adm publish repeats it rather than letting a request
for a month quietly become a week.
Seven days because the links are the point. The objects do not expire, only
the links to them, so the cost of a short window is having to publish again
to hand out a readable one — and a publish with nothing to upload now skips
both transfers and still mints fresh links, which makes re-issuing cheap.
Use --expire 1h when the link is for someone reading it immediately.
One signature authorises exactly one key. Taking the document's URL and
editing the path to the bundle returns 403 SignatureDoesNotMatch. The two
artifacts are therefore two separate grants: handing someone the document does
not hand them the repo, and that is a feature worth keeping rather than a gap
to close.
That property used to be load-bearing in the other direction: the document
quoted a signed link to the bundle, so adm publish had to upload the bundle
and sign it before rendering the page around that link. It does not any more.
The bundle is uploaded and left unsigned, and the only signature in a publish
is the document's own.
Which is a reduction worth being explicit about. Two grants became one, and the one that remains reaches the artifact built to be read rather than the one holding the machine's record in the clear.
This costs the document its checksum skip. A signature embeds the minute it
was made, so a published render differs from the previous one even when
nothing else changed, and --checksum always transfers it. That is the price
of the link being live, and it is one 390 KiB object; the bundle, which has no
signature anywhere near it, still skips when unchanged. A plain adm export embeds no
link and so remains byte-identical run to run.
The embedded link also puts PUBLISH_S3_ACCESS_KEY into the document, because
SigV4 carries the access key id in X-Amz-Credential and has nowhere else to
put it. The export's leak gate would refuse that, so it is given the narrowest
possible exemption: the exact URL adm publish embedded is removed from the
page before scanning, and nothing else is. The same value appearing anywhere
else is still a leak and still refused. Worth being clear about what is and is
not exposed — the secret key never appears in a signed URL, only a signature
derived from it, so it stays fully checked; and anyone holding a link to the
document already has the access key id in their address bar, though a saved
copy of the page carries it onward where a bare URL would not.
Is the link still good?
Whether a signature has lapsed depends on when the page is read, which is
the one thing a statically rendered document cannot know. So this is the
single exception to the no-script rule — an inline script, no external
request, the page still one file — which reads X-Amz-Date and
X-Amz-Expires out of the embedded URL and says either
Link good for another 6 days.
in green, or
Link expired 3 days ago.
in red. Without JavaScript the sentence stays as rendered: signed at such a time, for seven days.
It reports what the signature says, not whether the link works. Rotating
PUBLISH_S3_ACCESS_KEY invalidates every outstanding URL while leaving its
dates untouched, and the reader's own clock is taken on trust. Green means
"not yet expired according to its own fields", which is weaker than verified
and is worded that way.
History leaves by a different door
A bundle carries all history, including a blob committed and removed again.
The pre-push hook cannot help: publish talks to the object store directly, so
no push happens. Before building a bundle, adm publish therefore runs
hooks/scan-history — the same secret-scanning library the git hooks use,
over every blob reachable from every ref — and refuses to build if anything
is in the clear. See 015 for the scanner and for the list of
blobs reviewed and allowed.
--html-only skips the bundle and publishes the document alone.
Why rclone, and why its environment
rclone is a single static Go binary from extra with no runtime
dependencies, and sync/copyto are native verbs rather than a wrapper
around a storage API. The deciding reason, though, is credentials: rclone can
configure a remote entirely from environment variables
(RCLONE_CONFIG_<name>_*), so the keys arrive through sops exec-env into one
process and never touch a config file. aws-cli and s3cmd both want a
credentials file or a long command line.
That distinction matters more than it sounds. A command line is visible to
every user on the box through /proc/<pid>/cmdline; an environment is visible
only to the process owner and root. Neither is a vault, but putting a secret
key in argv would publish it to anyone with a shell here, which is the same
reasoning 070 uses for the agent's API key.
What the box needs
Five values in <box>.env, all encrypted — none of them is needed before
015 runs, so the default applies and nothing has to be
exempted:
| key | what |
|---|---|
PUBLISH_S3_ENDPOINT |
regional endpoint, e.g. https://nyc3.digitaloceanspaces.com |
PUBLISH_S3_BUCKET |
the Space |
PUBLISH_S3_PREFIX |
key prefix; defaults to $BOX_NAME |
PUBLISH_S3_ACCESS_KEY |
Spaces access key |
PUBLISH_S3_SECRET_KEY |
Spaces secret |
Add them with sops rather than by editing the file — see
015, which explains at length what hand-editing does to it.
Two documents, two audiences
adm export renders this machine: box values substituted, encrypted values
shown as ‹redacted›, the box's own ciphertext embedded, every source file
carried so the page can be mined back into a repository. It answers how do
I get this machine back, and it is private because the answer describes one
machine in detail.
adm export --generic renders the product. No box, so nothing to
substitute and nothing to redact: per-box values appear as the placeholders
the runbooks are written with, $ADM_ROOT and ${BOX_NAME}, because that
is the form you would write a new runbook in. It answers how do I get a
machine, and it is public — it is intended for GitHub Pages on the upstream
repository, where someone who has never seen this project arrives.
It carries no sources and no appendix. Those exist so a document handed over
as a link can be reconstructed into a repository, which matters when cloning
is not an option; the generic document's second step is git clone, from
a public URL it derives from the remote. Embedding would double the page for
a property the reader already has. The result is about half the size.
The leak gate still runs, over an empty set of values. A gate skipped because "there are no secrets in this one" is a gate that stops noticing when that changes.
Whatever builds the public page needs cmark-gfm as well as Python —
adm shells out to it for prose — which is the one dependency a CI runner
will not have by default.
.github/workflows/pages.yml is what builds it: on every push to the public
repository's master, it installs cmark-gfm, renders with
adm export --generic, checks the result is the document rather than merely
a file, and deploys to Pages. Publishing the page needs no credential of
ours — Actions deploys to its own repository's Pages — so unlike
adm publish there is nothing here to keep secret.
Two things about that file are worth knowing. It has to be enabled once by
hand: the repository's Pages source must be set to GitHub Actions, which is
a setting rather than a file. And its job is guarded on
github.server_url == 'https://github.com', because this same file is
synced into the private fork and Forgejo reads .github/workflows too —
without the guard, every push to the fork queues a job that cannot deploy to
Pages and may have no runner to say so. The guard is on the server rather
than the repository name, since the forge's address is the one thing that
distinguishes the two.
cd "$ADM_ROOT"
out="$(mktemp --suffix=.html)"
adm export --generic -o "$out" >/dev/null 2>&1 \
|| { echo "adm export --generic failed"; rm -f "$out"; exit 1; }
# The placeholders have to survive *inside executable blocks*. That is the
# whole of what "generic" means here: the blocks read as written rather than
# as they ran, so a new runbook can be written against them.
#
# Inside blocks specifically, and not by grepping the page, because the first
# two versions of this check did grep the page and both passed when they
# should have failed. $ADM_ROOT appears in prose that documents the
# placeholder, and in the private render it appears fifty-two times inside
# the embedded <template> sources -- so a whole-page grep answers yes no
# matter what substitution did. The blocks are the only place the question
# has meaning.
PYTHONDONTWRITEBYTECODE=1 python3 - "$out" <<'PY'
import html, io, re, sys
page = io.open(sys.argv[1], encoding="utf-8").read()
blocks = [html.unescape(re.sub(r"<[^>]+>", "", m.group(1)))
for m in re.finditer(
r'<div class="blk [^"]*">.*?<pre><code>(.*?)</code></pre>',
page, re.S)]
if not blocks:
raise SystemExit("no executable blocks rendered at all")
kept = [b for b in blocks if "$ADM_ROOT" in b]
if len(kept) < 10:
print("%d of %d blocks kept $ADM_ROOT; a generic render substitutes"
% (len(kept), len(blocks)))
print("nothing, so almost every block that uses it should still show it.")
raise SystemExit(1)
print("%d of %d blocks keep $ADM_ROOT unsubstituted" % (len(kept), len(blocks)))
PY
# Deliberately NOT asserting the box name is absent. That was the first
# version of this check and it failed immediately, correctly: the name
# appears twenty-two times in prose -- README and DECISIONS tell the story of
# this box being the first one, and several runbooks explain decisions by
# naming it. All of that is published upstream already, which is why
# generic-runbooks checks executable blocks for a box name and not prose.
#
# So the risk this block guards is not "the name appears" but "a value was
# substituted", and the placeholder above is the evidence for that. A check
# that looks obviously right can be wrong about what the repo actually
# promises; this one was.
# Neither of the two things the private document carries for reconstruction.
for pattern in 'class="src" data-path' 'id="bin-adm"'; do
if grep -qF "$pattern" "$out"; then
echo "the generic render embeds sources ($pattern); it should not"
rm -f "$out"; exit 1
fi
done
# The directive says create, not restore.
grep -qF 'Create a machine from these runbooks' "$out" \
|| { echo "the generic render has no creation directive"; rm -f "$out"
exit 1; }
# Titled for the project, not for a box that does not exist. This read
# "adm - adm" on the published page for a while: the per-box template with
# the box name standing in for the project. Taken from README's own first
# heading, so the check is that the two agree rather than that the title
# matches a string written here as well.
want="$(sed -n 's/^# //p' README.md | head -1)"
test -n "$want" || { echo "README has no h1 to take a title from"
rm -f "$out"; exit 1; }
grep -qF "<title>$want</title>" "$out" \
|| { echo "the generic render is not titled for the project:"
echo " README h1: $want"
grep -o '<title>[^<]*</title>' "$out" | sed 's/^/ render: /'
rm -f "$out"; exit 1; }
size=$(( $(stat -c%s "$out") / 1024 ))
rm -f "$out"
echo "generic render: ${size} KiB, placeholders intact, no embedded sources, creation directive present"
The box's own secrets, as ciphertext
The document carries hosts/<box>/<box>.env encrypted, plus the box's
.age.pub and its scan-history.allow. Without them a reconstruction can
rebuild the machine's configuration but not its credentials, and adm env-fill papers over that by asking a human to type the values back — which
is impossible for most of them. FORGE_TOKEN, PUBLISH_S3_SECRET_KEY and
LLM_API_KEY are not values an operator remembers, they are values an
operator had. Asking for them means issuing new ones, and a box with new
credentials is not the box this document describes.
It is ciphertext, so it changes nothing about who can read what: anyone holding this page could already read the repository it came from. What it does change is what the page is. It was semi-private documentation; it is now closer to an encrypted backup, and the seven-day limit on a pre-signed link carries weight it did not carry before.
Three files by exact name, never a directory walk. hosts/<box>/ also holds
facts/, AS-FOUND.md and log/, which are plaintext machine detail —
network configuration, accounts, a 24KB /etc manifest — and are precisely
what reached a public repository once before. A walk with exclusions would
publish anything added to that directory later. A list publishes nothing it
was not told about. The bundle carries the rest, because a bundle is handed
to someone deliberately where this page travels as a link.
adm export refuses outright if the env is not encrypted yet. A box that has
not reached 015 has its secrets in the clear on disk, and
there is no version of embedding that file which is recoverable afterwards.
cd "$ADM_ROOT"
out="$(mktemp --suffix=.html)"
adm export -o "$out" >/dev/null 2>&1 \
|| { echo "adm export failed"; rm -f "$out"; exit 1; }
# Pull each embedded hosts/ file back out of the page and judge it the way
# the commit hook judges the real one -- check_env from the scan library,
# against the same unencrypted_regex. A second idea of what counts as
# encrypted would be free to disagree with the one that blocks commits, and
# the weaker of the two would be the one standing between this document and
# a published secret.
. hooks/secret-scan.sh
ure="$(unencrypted_regex)"
tmp="$(mktemp -d)"
found="$(PYTHONDONTWRITEBYTECODE=1 python3 - "$out" "$tmp" <<'PY'
import html, io, os, re, sys
page = io.open(sys.argv[1], encoding="utf-8").read()
names = []
for m in re.finditer(r'<template class="src" data-path="(hosts/[^"]+)">(.*?)</template>',
page, re.S):
rel = html.unescape(m.group(1))
names.append(rel)
io.open(os.path.join(sys.argv[2], rel.replace("/", "__")), "w",
encoding="utf-8").write(html.unescape(m.group(2)))
print(" ".join(names))
PY
)"
# Nothing from the machine's own record, ever. Named individually rather than
# as a pattern, because the point is a list of what is forbidden and not a
# guess at its shape.
for bad in facts/ AS-FOUND.md log/; do
case "$found" in
*"$bad"*) echo "the export carries $bad, which is plaintext machine detail"
rm -rf "$out" "$tmp"; exit 1 ;;
esac
done
rc=0
for f in "$tmp"/*; do
[ -e "$f" ] || continue
case "$f" in
*__gitignore|*scan-history.allow|*.age.pub) continue ;;
esac
check_env "$(basename "$f")" "$ure" "$(cat "$f")" || rc=1
done
rm -rf "$out" "$tmp"
test "$rc" = 0 \
|| { echo "an env embedded in the export has a value in the clear"; exit 1; }
echo "export carries: $found"
echo "every embedded env is encrypted except what the policy exempts"
cd "$ADM_ROOT"
out="$(mktemp --suffix=.html)"
adm export -o "$out" >/dev/null 2>&1 \
|| { echo "adm export failed"; rm -f "$out"; exit 1; }
# The claim the appendix makes is that this page can be mined back into a
# working repository. Assert it rather than describe it: pull every embedded
# source and the runner out of the rendered page and compare each with the
# file it came from. An escaping bug anywhere in that path would otherwise
# surface as a repository that looks right and does not run.
python3 - "$out" "$ADM_ROOT" <<'PY'
import hashlib, html, io, os, re, sys
page = io.open(sys.argv[1], encoding="utf-8").read()
root = sys.argv[2]
bad = n = 0
def check(rel, text):
global bad, n
n += 1
path = os.path.join(root, rel)
try:
want = io.open(path, encoding="utf-8").read()
except OSError:
print(" %s: embedded but not in the repo" % rel); bad += 1; return
if text != want:
a = hashlib.sha256(text.encode()).hexdigest()[:12]
b = hashlib.sha256(want.encode()).hexdigest()[:12]
print(" %s: embedded %s, on disk %s" % (rel, a, b)); bad += 1
for m in re.finditer(
r'<template class="src" data-path="([^"]+)">(.*?)</template>',
page, re.S):
check(html.unescape(m.group(1)), html.unescape(m.group(2)))
m = re.search(r'<pre id="adm-source"><code>(.*?)</code></pre>', page, re.S)
if not m:
print(" the runner is not in the page"); bad += 1
else:
check("bin/adm", html.unescape(re.sub(r"<[^>]+>", "", m.group(1))))
# And that the digest the page prints is the one a reader would compute.
d = re.search(r"<pre><code>([0-9a-f]{64})</code></pre>", page)
if not d:
print(" no sha256 printed beside the runner"); bad += 1
else:
real = hashlib.sha256(
io.open(os.path.join(root, "bin/adm"), "rb").read()).hexdigest()
if d.group(1) != real:
print(" the page prints a digest that is not the runner's"); bad += 1
if bad:
sys.exit(1)
print("%d sources and the runner survive the round trip" % n)
PY
rc=$?
rm -f "$out"
exit "$rc"
Runbooks are supposed to be generic, and until now nothing checked it. The rule has two tiers, because naming a box is not equally wrong everywhere.
A box’s name may not appear in an executable block. That is code which
runs, and a name baked into it makes the procedure wrong on the next machine.
It never needs to be there: adm substitutes $BOX_NAME, $ADM_ROOT and
every other env key before a block runs, so there are zero violations today
and this is purely a ratchet.
No link may point inside a per-box directory. A link to
hosts/<name>/AS-FOUND.md resolves on the one machine that owns that
directory and is dead everywhere else. There were eight, two of them in the
paragraph of AGENTS.md that states the rule. They are now plain code spans,
because no generic link can point at a per-box file. This tier covers the
documents as well as the runbooks, since all four ship in the public repo and
a dead link is dead wherever it sits.
Note what this tier does not do: look for this box's name. Any link into
hosts/<anything>/ fails, including one pointing at somebody else's box. That
is both stricter and simpler, and it has a useful side effect — the tier
cannot be confused by what a box is called.
Prose and illustrative blocks may name a box, deliberately. Substitution
reaches executable blocks only — measured: zero $ADM_ROOT left in them
against three surviving in illustrative ones. So writing <box> into an
example would not make the published document generic, it would make it
useless: sops rea-dev/rea-dev.env is a command a reader can paste, and
sops <box>/<box>.env is a placeholder. The generic source lives in the
templates; the rendered page is allowed to be about this machine.
If that bet turns out wrong, the fix is to move one category from permitted to failing — which is why the tiers are separate conditions rather than one regex.
When the box is called something unfortunate
Tier 1 has to search for a name, and a name can be ordinary text. Matching is
on word boundaries, so a box called ls no longer matches inside tools or
false — that alone took it from 97 hits to 5. But boundaries cannot help
when the name is a word: echo matches 400 times, test 119, and box
hits every <box> placeholder in the prose.
There is no pattern that distinguishes "the machine called test" from the
shell builtin, so the check does not pretend otherwise. It reports the first
ten lines and then says what is probably happening, at any count rather than
past some threshold — five lines of correct shell flagged as mistakes is
worse than four hundred, because the operator has no reason to suspect the
check rather than their own code. The remedy is to rename the box or to list
the lines in hosts/<box>/generic.allow.
Tier 2 is unaffected, being name-independent: zero false positives for every
name tried, including a.
cd "$ADM_ROOT"
# The forge's hostname, derived rather than stored again -- 020 does the
# same, for the same reason. It is one installation's address, and has no
# business in a file the public repo carries.
forge="$(printf '%s' "${GIT_REMOTE:-}" | sed -E 's|^[a-z+]+://||; s|^[^@]*@||; s|[:/].*$||')"
# What adm upstream refuses to publish, read from adm rather than written
# here again. Restating it is how tier 4 came to be needed at all.
never="$(adm upstream -n 2>/dev/null | sed -n 's/^never published: //p')"
test -n "$never" \
|| { echo "adm upstream did not report what it never publishes"; exit 1; }
python3 - "$BOX_NAME" "$forge" "$never" <<'PY'
import glob, io, os, re, sys
box = sys.argv[1]
forge = sys.argv[2] if len(sys.argv) > 2 else ""
never = (sys.argv[3] if len(sys.argv) > 3 else "").split()
if not box:
sys.exit("BOX_NAME is empty; nothing to check against")
docs = ["README.md", "AGENTS.md", "DECISIONS.md"]
runbooks = sorted(glob.glob("runbooks/*.md"))
# Tier 2 does not look for this box at all: any link into a per-box
# directory is dead in every repo but the one that owns that box,
# including a link to somebody else’s. Being name-independent also
# makes it immune to a box called something ordinary.
link = re.compile(r"\]\(([^)]*hosts/[^/)<>]+/[^)]*)\)")
# Tier 1 has to look for the name, and does so on word boundaries: a
# box called "ls" would otherwise match inside "tools" and "false".
name = re.compile(r"(?<![\w-])" + re.escape(box) + r"(?![\w-])")
# Tier 3: the forge hostname, anywhere in a generic file. The export leak
# gate cannot do this -- it excuses any encrypted value already present in
# the source, so a hostname written into prose exempts itself.
host = re.compile(re.escape(forge)) if forge else None
bad = []
for path in docs + runbooks:
lines = io.open(path, encoding="utf-8").read().split("\n")
fence = executable = False
for n, text in enumerate(lines, 1):
m = re.match(r"^```(.*)$", text)
if m:
if not fence:
info = m.group(1).strip()
executable = info.startswith("bash") and "id=" in info
fence = not fence
continue
if host and host.search(text):
bad.append((path, n, "forge hostname", text.strip()))
if fence and executable:
if name.search(text):
bad.append((path, n, "executable block", text.strip()))
elif not fence:
for g in link.finditer(text):
bad.append((path, n, "link target", g.group(1)))
# Tier 4: files that are not markdown. They have no prose to be excused by,
# so any mention of the box is a defect -- unless adm upstream never publishes
# the file. .sops.yaml is the case that motivated this: 015 generates it with
# this box's own rule and recipients, and it reached the public repo because
# tier 1 reads executable blocks and tier 2 reads links, and neither looks at
# YAML.
import subprocess
tracked = subprocess.run(["git", "ls-files"], capture_output=True,
text=True).stdout.split()
for path in tracked:
if path.endswith(".md"):
continue
if any(path == e.rstrip("/") or path.startswith(e.rstrip("/") + "/")
for e in never):
continue
try:
body = io.open(path, encoding="utf-8", errors="replace").read()
except OSError:
continue
for n, text in enumerate(body.split("\n"), 1):
if name.search(text):
bad.append((path, n, "published file", text.strip()))
allowed = set()
for f in glob.glob("hosts/*/generic.allow"):
for ln in io.open(f, encoding="utf-8"):
ln = ln.split("#", 1)[0].strip()
if ln:
allowed.add(ln)
bad = [b for b in bad if "%s:%d" % (b[0], b[1]) not in allowed]
for path, n, kind, text in bad[:10]:
why = {"executable block": "names %s in an executable block" % box,
"link target": "links inside a per-box directory",
"forge hostname": "names the forge host",
"published file":
"names %s, and adm upstream publishes it" % box,
}[kind]
print(" %s:%d %s: %s" % (path, n, why, text[:70]))
if len(bad) > 10:
print(" ... and %d more" % (len(bad) - 10))
if bad:
blocks = sum(1 for b in bad if b[2] == "executable block")
print()
if blocks:
print(" an executable block must use $BOX_NAME; adm substitutes it")
# No threshold on this hint, because even one is confusing when it is
# a collision. A box named "ls" produces five, and five lines of
# correct shell flagged as mistakes is worse than four hundred: the
# operator has no reason to suspect the check rather than the code.
print(" if these are ordinary shell rather than a hardcoded name,"
" then")
print(" BOX_NAME=%s collides with the text of these runbooks. No"
% box)
print(" pattern can tell those apart -- rename the box, or allow the")
print(" lines in hosts/<box>/generic.allow.")
if any(b[2] == "link target" for b in bad):
print(" a link into hosts/<box>/ resolves on one machine only --"
" drop the link")
if any(b[2] == "forge hostname" for b in bad):
print(" the forge host comes from $GIT_REMOTE; writing it into a"
" file the")
print(" public repo carries puts one installation's address in"
" everyone's")
sys.exit(1)
print("no generic file names %s or the forge host, and no link points"
" inside hosts/<box>/" % box)
PY
cd "$ADM_ROOT"
# The contents list is built from each document's own `# ` heading rather than
# from its filename, so a heading that disagrees with the file it sits in now
# shows a wrong number to every reader. 050 read "006 - Boot into snapshots"
# for some time, and nothing noticed until the contents started quoting it.
rc=0
for f in runbooks/*.md; do
n="$(basename "$f" | grep -oE '^[0-9]+')"
h="$(grep -m1 '^# ' "$f")"
hn="$(printf '%s' "$h" | grep -oE '^# [0-9]+' | grep -oE '[0-9]+')"
if [ -z "$hn" ]; then
echo "$f: first heading does not start with a number: $h"; rc=1
elif [ "$n" != "$hn" ]; then
echo "$f: filename says $n, heading says $hn"; rc=1
fi
done
test "$rc" = 0 || exit 1
echo "every runbook heading agrees with its filename"
sudo pacman -S --needed --noconfirm rclone
rclone version | head -1
command -v rclone >/dev/null || { echo "rclone not on PATH"; exit 1; }
# Assert the two capabilities this design rests on rather than just the
# binary: an s3 backend, and link, which is what produces the pre-signed URL.
rclone help backends 2>/dev/null | grep -qw s3 \
|| { echo "this rclone has no s3 backend"; exit 1; }
rclone link --help >/dev/null 2>&1 \
|| { echo "this rclone cannot generate pre-signed links"; exit 1; }
echo "rclone $(rclone version | head -1 | awk '{print $2}') with s3 and link"
adm publish -n # what it would do, without doing it
adm publish # render, bundle, upload, sign the document only
adm publish --expire 1h # a link for right now
adm publish --html-only # the document alone, no repo bundle
adm publish renders before it uploads rather than sending whatever is in
export/, which means a stale document cannot be published and the secret
gate runs every time, before any bytes leave the box. It fails closed: a render
that would contain a value encrypted in the env and absent from the source
never becomes a file, let alone an object.
--s3-no-check-bucket is passed deliberately. Without it copyto attempts
CreateBucket when the target is missing, which was observed on the first real
run — a typo in PUBLISH_S3_BUCKET would have had this command provision a new
Space and publish into it. Publishing is not a provisioning operation, and a
missing bucket is an error rather than an invitation.
cd "$ADM_ROOT"
if ! adm publish -n >/dev/null 2>&1; then
# Conditional on purpose: a box that has not been given Spaces credentials
# is not misconfigured, and failing for want of them would be failing for
# the wrong reason.
echo "publishing not configured (PUBLISH_S3_* absent); nothing to check"
exit 0
fi
out="$(adm publish -n)"
grep -q 'would upload: .*/index.html' <<<"$out" \
|| { echo "adm publish -n does not describe the document upload:"; echo "$out"; exit 1; }
# Both artifacts, because the bundle is the half that is easy to lose: it is
# skippable with --html-only, and a regression there would publish a readable
# document and quietly stop publishing anything you could clone.
grep -q "would upload: .*/$BOX_NAME.bundle" <<<"$out" \
|| { echo "adm publish -n does not describe the repo bundle:"; echo "$out"; exit 1; }
grep -q 'history is scanned' <<<"$out" \
|| { echo "the dry run does not mention the history scan"; exit 1; }
adm publish -n --html-only | grep -q "$BOX_NAME.bundle" \
&& { echo "--html-only still describes a bundle upload"; exit 1; }
grep -q 'RCLONE_CONFIG_PUB_SECRET_ACCESS_KEY' <<<"$out" \
|| { echo "the remote is not configured from the environment"; exit 1; }
# The dry run must name the variables and never their values. If this ever
# fails, a key is being printed to a terminal and into adm's run logs.
for k in PUBLISH_S3_ACCESS_KEY PUBLISH_S3_SECRET_KEY; do
v="$(sops -d --input-type dotenv --output-type dotenv \
"$ADM_BOX_DIR/$BOX_NAME.env" \
| awk -F= -v k="$k" '$1==k {print substr($0, length(k)+2)}')"
[[ -z "${v// /}" ]] && continue
if grep -qF -- "$v" <<<"$out"; then
echo "adm publish -n printed the value of $k"
exit 1
fi
done
echo "publish configured; the dry run names the remote and not the keys"