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:
chainoutside of parentheses is refused (it would otherwise switch onset -efor 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 -eis 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.CONTENTfrom 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.topgoes 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--/runis emptied at every boot, so a missingboot.reportmeans 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