pave

README.md

pave๐Ÿ”—

pave converges a machine onto goals written in plain bash.

Goals live in a goals directory (usually its own repo), sorted by phase -- when they run, and as whom -- and optionally by hostname. Triggers at boot, graphical session start, and shell login all call the same thing:

pave run GOALS_DIR PHASE

Goals are expected to be idempotent: running them again on a converged machine changes nothing, and says nothing. That's what makes it fine to run them often, from several triggers, and by hand.

pave needs bash (4.4 or later) and coreutils. It's Linux-first.

Quick start๐Ÿ”—

git clone ... pave && make -C pave install    # into ~/made/pave/; or run pave/bin/pave in place

mkdir -p ~/goals
cat > ~/goals/boot.bash <<'EOF'
( chain lru_gen
	write_knob /sys/kernel/mm/lru_gen/enabled y
	write_knob /sys/kernel/mm/lru_gen/min_ttl_ms 1000
)
EOF

pave install ~/goals

pave install sets up every trigger this machine supports, asks sudo for the root-owned one, and runs the boot phase right away.

The goals directory๐Ÿ”—

GOALS_DIR/
  boot.bash
  session.bash
  login.bash
  manual.bash
  hosts/
    HOSTNAME/
      boot.bash
      session.bash

pave run GOALS_DIR PHASE runs PHASE.bash, then hosts/$HOSTNAME/PHASE.bash, each if it exists. Every file is optional. The hostname is matched literally, as bash's $HOSTNAME reports it. Any *.bash file in the top dir or a host dir that isn't named for a phase is an error, so a misspelled phase can't silently never run. Other files and directories (helpers, config files to install) are yours to arrange.

phases๐Ÿ”—

phase runs as triggered by
boot root once per boot: a systemd unit, an OpenRC local.d script, /etc/rc.local, or a cron @reboot line
session you graphical session start: an XDG autostart entry
login you login shells: a block in ~/.bash_profile, ~/.bash_login, or ~/.profile (whichever bash reads)
manual either nothing; it runs when you run it

Any phase can also be run by hand at any time.

Writing goals๐Ÿ”—

Goal files are bash, sourced by pave, with the goals dir as the working directory and an empty stdin. They run under set -uo pipefail, but not set -e.

every top-level command stands alone๐Ÿ”—

A failing command is recorded, with its file and line, and the file carries on with the next one. Independent things need no ceremony:

xinput set-prop 'SynPS/2 Synaptics TouchPad' 'libinput Accel Speed' 0.4
ensure_file "$HOME/.config/git/ignore" 644 "$(<files/gitignore)"

chains: things that must happen together๐Ÿ”—

When later steps only make sense if earlier ones worked, put them in a chain:

( chain dotfiles
	test -d "$HOME/src/dotfiles/.git"
	ensure_link src/dotfiles/vimrc "$HOME/.vimrc"
	ensure_link src/dotfiles/tmux.conf "$HOME/.tmux.conf"
)

Here, if the dotfiles checkout isn't there, no links get pointed at nothing.

A chain is a parenthesized group whose first command is chain NAME. It stops at its first failing command -- including one inside a function it calls -- and that counts as one failure. The file carries on after it.

A failed chain doesn't undo anything; the next run picks up where it stopped.

Two things that defeat a chain:

  • chain outside of parentheses is refused (it would otherwise switch on set -e for the rest of the file).
  • A chain used as a condition (if ( chain ... ), or ( chain ... ) || something) doesn't stop at failures. That's bash: set -e is ignored inside anything being tested as a condition.

whole-file aborts๐Ÿ”—

An unset variable (or an exit) ends the file; it's recorded, and the host file still runs. A file with a syntax error doesn't run at all.

primitives๐Ÿ”—

Each reports a change only when it made one, and refuses to replace anything that isn't the kind of thing it manages. All paths must be absolute.

  • ensure_file PATH MODE CONTENT -- whole-file content (plus one trailing newline) and octal mode. Parent dirs are created. CONTENT from a file in the goals dir: "$(<files/thing.conf)".
  • ensure_link TARGET PATH -- a symlink with exactly this target text. Parent dirs are created.
  • ensure_block FILE top|bottom NAME CONTENT -- a marker-delimited block of lines inside a file that otherwise belongs to someone else. top goes after a leading #! line. A block found elsewhere in the file is moved.
  • write_knob PATH VALUE -- write a sysfs or procfs setting. A change is reported when the read-back value moved, since these files often read back in a different format than they accept.
  • changed DESCRIPTION -- report a change you made yourself, with plain commands.

Primitives return nonzero on failure, so they work in chains, outside them, and in conditions alike.

Triggers๐Ÿ”—

pave install GOALS_DIR

Run it as yourself. It installs the session and login triggers for you, then runs sudo pave install-boot GOALS_DIR, which installs one boot trigger and runs the boot phase. The boot trigger kind is the first of these the machine has: a running systemd, /etc/local.d/, /etc/rc.local, /etc/cron.d/.

Re-running it is harmless, and repoints every trigger if the goals dir changed. Every trigger is one line, "PAVE" run "GOALS_DIR" PHASE, with both paths absolute as they were when you installed. So if pave or the goals dir moves, re-run pave install.

root runs whatever is in those files๐Ÿ”—

The boot trigger has root run pave and your goal files from wherever they are. Whoever can write to either can therefore run anything as root on the next boot. If that's your user account, your user account is now root-equivalent. Decide whether that's acceptable for your machine.

Reports๐Ÿ”—

A run prints what it changed and what failed, then a summary line; a run that did neither prints nothing. Each run also writes everything it printed, plus the summary, to a report file:

  • root: /run/pave/PHASE.report -- /run is emptied at every boot, so a missing boot.report means no boot trigger fired.
  • users: ${XDG_STATE_HOME:-~/.local/state}/pave/PHASE.report.

exit codes๐Ÿ”—

code meaning
0 everything converged
4 usage error
5 goals dir not found
6 a *.bash file in the goals dir isn't named for a phase (the goals still ran)
7 a goal failed
8 the report dir couldn't be created
9 a path can't be written into a trigger (pave allows letters, digits, spaces, and ._/+@,:=~-)
10 no boot hook pave knows how to use
11 a trigger couldn't be installed
12 sudo pave install-boot failed
13 pave install was run as root

When more than one applies, the code is for the first one pave ran into.

Contributing๐Ÿ”—

Run make setup once after cloning: it activates the git hooks. Run make to see all commands. Before committing anything, run make all. There should be no warning messages.

The tests (tests/*.test) drive the real bin/pave against goal files in a scratch dir; boot triggers install into a fake root, set by PAVE_SYSROOT.

License๐Ÿ”—

SPDX-License-Identifier: Apache-2.0 OR MIT

README.md ยท 7.1 KiB ยท 7240887182