pave: converge a machine onto goal files written in bash.
commit 724088718266312bbfc1e36c5afa9e8860a22db6 · parent b131e7c978
· browse this tree
Ximianth, 2026-10-17 20:31:19 UTC
`pave run GOALS_DIR PHASE` sources PHASE.bash, then hosts/$HOSTNAME/PHASE.bash. Phases are a fixed list (boot, session, login, manual), each tied to the triggers pave knows how to install; a goal file not named for a phase is an error, so a typo can't silently never run. Failure semantics, and why they're shaped this way: - Top-level commands run without errexit, under an ERR trap: each failure is recorded with file:line and the file carries on. Unrelated goals shouldn't block each other. - `( chain NAME ... )` switches errexit on for one subshell, so things that must happen together stop together. chain refuses to run outside a subshell, since it would otherwise turn errexit on for the whole file. - A failure inside a subshell also surfaces as a failed subshell in every enclosing shell. A tally file (counts survive subshells; variables don't) lets each shell see that a deeper one already recorded it, so each failure is reported exactly once, at the deepest level. - `source` returns the status of a file's last command, which the trap has already recorded; the trap recognizes that case by FUNCNAME and skips it. - Goal files and chains must never run in a condition context (`x || y`, `if x`): bash ignores `set -e` inside those, which would silently disable every chain. Comments mark the two places this matters. - Each goal file runs in its own subshell, so an unset variable or `exit` aborts only that file. Files with syntax errors don't run at all (checked with `bash -n` first), rather than running up to the error. Primitives (ensure_file, ensure_link, ensure_block, write_knob) never rely on errexit internally, so they fail correctly in chains, outside them, and in conditions. Their writes are temp + rename. `pave install GOALS_DIR`, run as the user, installs the XDG autostart and login-profile triggers, then sudo's itself into `install-boot`, which picks one boot hook (systemd, OpenRC local.d, rc.local, cron.d, in that order of certainty), installs it, and runs the boot phase immediately. Reports go to /run/pave for root (emptied at boot, so a missing boot.report means no trigger fired) and the XDG state dir for users (always exists, unlike XDG_RUNTIME_DIR on machines without logind). A converged run prints nothing. Tests drive the real bin/pave against scratch goal dirs, with a fake root (PAVE_SYSROOT) and a pass-through fake sudo for install. Every assertion group was watched failing against a deliberate sabotage of the logic it covers. Also checked by hand: a root run against the real lru_gen sysfs files, writing their current values back (no change reported, report in /run/pave).
diff --git a/Makefile b/Makefile index 2fd6d68..bed0320 100644 --- a/Makefile +++ b/Makefile @@ -5,8 +5,11 @@ SHELL = /bin/bash MADE ?= $(HOME)/made +# shfmt finds the .bash files in these dirs by itself, but not the .test files. +SOURCES = bin lib tests tests/*.test + .PHONY: all -all: fmt build test ## Everything that's a must before committing: fmt, build, test. +all: fmt test ## Everything that's a must before committing: fmt, test. .PHONY: help help: ## Print info about all commands @@ -16,29 +19,24 @@ help: ## Print info about all commands | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[01;32m%-20s\033[0m %s\n", $$1, $$2}' .PHONY: setup -setup: ## One-time dev env setup: activate git hooks (and install deps, if any). +setup: ## One-time dev env setup: activate git hooks. git config core.hooksPath hack/git-hooks .PHONY: fmt fmt: ## Format source in place. - $(error PLACEHOLDER: formatter invocation) + shfmt -w $(SOURCES) .PHONY: check check: ## Verify-only and fast; run by the pre-commit hook on every commit. - $(error PLACEHOLDER: format check in verify mode, plus other fast verifications at this project's discretion) - -.PHONY: build -build: ## Build (dev profile). - $(error PLACEHOLDER: build invocation) + shfmt -d $(SOURCES) + shellcheck -x -S warning bin/pave lib/*.bash tests/*.test tests/testlib.bash .PHONY: test test: ## Run all tests. - $(error PLACEHOLDER: test invocation) - -.PHONY: clean -clean: ## Remove regenerable outputs. - $(error PLACEHOLDER: removals) + for t in tests/*.test; do "$$t" || exit; done .PHONY: install -install: ## Install binaries into $(MADE)/PLACEHOLDER/bin/. - $(error PLACEHOLDER: build or copy outputs into "$(MADE)/PLACEHOLDER/bin/") +install: ## Install into $(MADE)/pave/ (bin/ and lib/). + rm -rf "$(MADE)/pave" + mkdir -p "$(MADE)/pave" + cp -R bin lib "$(MADE)/pave/" diff --git a/README.md b/README.md index ced3572..43ca346 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,191 @@ -PLACEHOLDER -========= +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: + +```bash +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: + +```bash +( 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 @@ -10,6 +196,9 @@ 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 ------- diff --git a/bin/pave b/bin/pave new file mode 100755 index 0000000..894dcdc --- /dev/null +++ b/bin/pave @@ -0,0 +1,50 @@ +#!/bin/bash +## pave converges a machine onto goal files written in bash. See the README. + +set -euo pipefail + +## _pave_self prints the physical path of this script, following any symlink chain used to invoke it. +## Pure bash with `cd -P` in a subshell, plus plain `readlink`; +## deliberately not `readlink -f` or `realpath`, which differ across platforms. +_pave_self() ( + local src=${BASH_SOURCE[0]} _ + for _ in {1..40}; do # symlink-chain guard, in the spirit of the kernel's ELOOP + case $src in + /*/*) cd -P -- "${src%/*}" ;; + /*) cd -P -- / ;; + */*) cd -P -- "${src%/*}" ;; + esac + src=${src##*/} + [ -L "$src" ] || break + src=$(readlink -- "$src") + done + printf '%s/%s' "${PWD%/}" "$src" +) + +usage() { + cat <<-EOF + usage: + pave run GOALS_DIR PHASE converge this machine onto one phase's goals + pave install GOALS_DIR install all triggers (as your user; uses sudo for the boot one) + pave install-boot GOALS_DIR install the boot trigger and run the boot phase (as root) + + phases: ${PAVE_PHASES[*]} + EOF +} + +PAVE_BIN=$(_pave_self) +PAVE_ROOT=${PAVE_BIN%/bin/*} +source "$PAVE_ROOT/lib/load.bash" + +cmd=${1:-} +shift || : +case $cmd in +run) _pave_cmd_run "$@" ;; +install) _pave_cmd_install "$@" ;; +install-boot) _pave_cmd_install_boot "$@" ;; +help | -h | --help) usage ;; +*) + >&2 usage + exit 4 + ;; +esac diff --git a/lib/fs.bash b/lib/fs.bash new file mode 100644 index 0000000..2849768 --- /dev/null +++ b/lib/fs.bash @@ -0,0 +1,209 @@ +## fs.bash: primitives for files, symlinks, and managed blocks. +## +## Every primitive reports a change only when it made one, refuses (rather than clobbers) +## anything that isn't the kind of thing it manages, and replaces files by temp + rename, +## so there's never a half-written file. +## They never rely on errexit, so they behave the same inside chains, outside them, and in conditions. + +## _pave_need_abs fails unless every argument is an absolute path. +## (Relative paths would resolve against the goals dir, which is never what a goal means.) +_pave_need_abs() { + local p + for p in "$@"; do + if [[ $p != /* ]]; then + _pave_error "'$p' is not an absolute path" + return 1 + fi + done +} + +## _pave_read_exact reads a whole file into the named variable, trailing newlines included. +## (`$(<file)` would strip them, and then a file differing only in those would read as converged.) +## +## - @param $1 variable name. +## - @param $2 file path. +_pave_read_exact() { + if [ ! -r "$2" ]; then + _pave_error "$2 is not readable" + return 1 + fi + # read returns 1 at end of file, which is the normal way for it to stop here. + IFS= read -r -d '' "$1" <"$2" || : +} + +## _pave_replace atomically replaces a file with new content and mode. +## +## - @param $1 file path. +## - @param $2 content, written exactly. +## - @param $3 octal mode, or empty for the umask default. +_pave_replace() { + local path=$1 content=$2 mode=$3 tmp="$1.pave.$$" + if ! { + printf '%s' "$content" >"$tmp" && + { [ -z "$mode" ] || chmod "$mode" -- "$tmp"; } && + mv -T -- "$tmp" "$path" + }; then + rm -f -- "$tmp" + return 1 + fi +} + +## ensure_file converges a regular file's whole content and mode. +## CONTENT is written followed by one newline, +## so both `"$(< somefile)"` and quoted literals come out as conventional text files. +## Parent directories are created as needed. +## +## - @param $1 absolute path. +## - @param $2 octal mode, e.g. 644. +## - @param $3 content. +ensure_file() { + if [ "$#" -ne 3 ]; then + _pave_error "usage: ensure_file PATH MODE CONTENT" + return 1 + fi + local path=$1 mode=$2 want=$3$'\n' have='' havemode + _pave_need_abs "$path" || return 1 + if [[ ! $mode =~ ^[0-7]{3,4}$ ]]; then + _pave_error "ensure_file $path: mode '$mode' is not octal" + return 1 + fi + mode=$(printf '%o' "$((8#$mode))") # the same spelling `stat -c %a` uses + + if [ -L "$path" ] || { [ -e "$path" ] && [ ! -f "$path" ]; }; then + _pave_error "$path exists and is not a regular file; refusing to replace it" + return 1 + fi + if [ ! -e "$path" ]; then + mkdir -p -- "${path%/*}/" || return 1 + _pave_replace "$path" "$want" "$mode" || return 1 + changed "created $path" + return 0 + fi + + _pave_read_exact have "$path" || return 1 + havemode=$(stat -c %a -- "$path") || return 1 + if [ "$have" != "$want" ]; then + _pave_replace "$path" "$want" "$mode" || return 1 + changed "rewrote $path" + elif [ "$havemode" != "$mode" ]; then + chmod "$mode" -- "$path" || return 1 + changed "chmod $mode $path (was $havemode)" + fi +} + +## ensure_link converges a symlink to have exactly the given target text. +## Parent directories are created as needed. +## +## - @param $1 target text (relative targets are relative to the link's directory, as always). +## - @param $2 absolute path of the link. +ensure_link() { + if [ "$#" -ne 2 ]; then + _pave_error "usage: ensure_link TARGET PATH" + return 1 + fi + local target=$1 path=$2 have tmp + _pave_need_abs "$path" || return 1 + if [ -L "$path" ]; then + have=$(readlink -- "$path") || return 1 + [ "$have" = "$target" ] && return 0 + tmp="$path.pave.$$" + if ! { ln -s -- "$target" "$tmp" && mv -T -- "$tmp" "$path"; }; then + rm -f -- "$tmp" + return 1 + fi + changed "repointed $path -> $target (was $have)" + elif [ -e "$path" ]; then + _pave_error "$path exists and is not a symlink; refusing to replace it" + return 1 + else + mkdir -p -- "${path%/*}/" || return 1 + ln -s -- "$target" "$path" || return 1 + changed "linked $path -> $target" + fi +} + +## ensure_block converges one named, marker-delimited block of lines +## inside a file that otherwise belongs to someone else (a shell rc file, rc.local, ...). +## Exactly one block of that name ends up in the file, with the given content, at the given end. +## A block found elsewhere in the file is moved there. +## 'top' goes after a leading `#!` line, if the file has one. +## The file's mode is kept; a file that doesn't exist yet is created. +## +## - @param $1 absolute path of the file. +## - @param $2 placement: top or bottom. +## - @param $3 block name; becomes part of the marker lines. +## - @param $4 content: the lines between the markers. +ensure_block() { + if [ "$#" -ne 4 ]; then + _pave_error "usage: ensure_block FILE top|bottom NAME CONTENT" + return 1 + fi + local file=$1 placement=$2 name=$3 content=$4 + _pave_need_abs "$file" || return 1 + case $placement in + top | bottom) ;; + *) + _pave_error "ensure_block $file: placement must be top or bottom, not '$placement'" + return 1 + ;; + esac + if [ -z "$name" ] || [[ $name == *$'\n'* ]]; then + _pave_error "ensure_block $file: block name must be one non-empty line" + return 1 + fi + local begin="# >>> pave:$name >>>" end="# <<< pave:$name <<<" + local block=$begin$'\n'$content$'\n'$end + + local -a others=() + local in_block=0 saw_block=0 line have='' mode='' + if [ -e "$file" ]; then + if [ -L "$file" ] || [ ! -f "$file" ]; then + _pave_error "$file exists and is not a regular file; refusing to replace it" + return 1 + fi + _pave_read_exact have "$file" || return 1 + mode=$(stat -c %a -- "$file") || return 1 + while IFS= read -r line || [ -n "$line" ]; do + if [ "$in_block" -eq 0 ] && [ "$line" = "$begin" ]; then + in_block=1 + saw_block=1 + elif [ "$in_block" -eq 1 ]; then + [ "$line" = "$end" ] && in_block=0 + else + others+=("$line") + fi + done <<<"${have%$'\n'}" + if [ "$in_block" -eq 1 ]; then + _pave_error "$file: block '$name' has a begin marker but no end marker (fix the file by hand)" + return 1 + fi + fi + + # `<<<` adds a newline, so an empty file reads as one empty line; that's not content. + if [ "${#others[@]}" -eq 1 ] && [ -z "${others[0]}" ]; then + others=() + fi + local -a lines=() + if [ "$placement" = top ]; then + if [ "${#others[@]}" -gt 0 ] && [[ ${others[0]} == '#!'* ]]; then + lines=("${others[0]}" "$block" "${others[@]:1}") + else + lines=("$block" "${others[@]}") + fi + else + lines=("${others[@]}" "$block") + fi + local want + printf -v want '%s\n' "${lines[@]}" + [ "$have" = "$want" ] && return 0 + + if [ ! -e "$file" ]; then + mkdir -p -- "${file%/*}/" || return 1 + fi + _pave_replace "$file" "$want" "$mode" || return 1 + if [ "$saw_block" -eq 1 ]; then + changed "updated block '$name' in $file" + else + changed "added block '$name' to $file" + fi +} diff --git a/lib/install.bash b/lib/install.bash new file mode 100644 index 0000000..61f3931 --- /dev/null +++ b/lib/install.bash @@ -0,0 +1,158 @@ +## install.bash: `pave install` and `pave install-boot`, which set up the triggers that call `pave run`. +## +## Every trigger's payload is one `pave run` line with absolute paths baked in: +## triggers run with other users' homes, other working dirs, and minimal environments. +## Boot triggers go under $PAVE_SYSROOT (default: the real root), so they can be installed into a fake root. + +## _pave_trigger_path_ok fails unless a path can be written into every trigger format unescaped. +## (Each format has its own quoting rules -- shell, systemd, desktop entries, cron -- +## and a conservative character set is simpler than four escapers.) +_pave_trigger_path_ok() { + if [[ ! $1 =~ ^/[A-Za-z0-9._/+@,:=~\ -]*$ ]]; then + _pave_error "'$1' has characters pave won't put in a trigger; use a path made of letters, digits, spaces, and ._/+@,:=~-" + return 1 + fi +} + +## _pave_payload prints the one command line every trigger runs. +## +## - @param $1 goals dir. +## - @param $2 phase. +_pave_payload() { + printf '"%s" run "%s" %s' "$PAVE_BIN" "$1" "$2" +} + +## _pave_detect_boot prints the boot trigger kind for this machine: the first match in a fixed order. +## The order goes from the most certain evidence (a running systemd) to the least +## (a cron.d directory doesn't prove a cron daemon reads it). +_pave_detect_boot() { + local r=${PAVE_SYSROOT:-} + if [ -d "$r/run/systemd/system" ]; then + printf systemd + elif [ -d "$r/etc/local.d" ]; then + printf openrc-local + elif [ -f "$r/etc/rc.local" ]; then + printf rc-local + elif [ -d "$r/etc/cron.d" ]; then + printf cron-reboot + else + _pave_error "found no boot hook pave knows: no systemd, /etc/local.d, /etc/rc.local, or /etc/cron.d" + return 1 + fi +} + +_pave_trigger_systemd() { + local d="${PAVE_SYSROOT:-}/etc/systemd/system" + ensure_file "$d/pave-boot.service" 644 "[Unit] +Description=pave boot goals +After=local-fs.target + +[Service] +Type=oneshot +ExecStart=$(_pave_payload "$1" boot) + +[Install] +WantedBy=multi-user.target" || return 1 + # What `systemctl enable` would do, without needing systemctl. + ensure_link ../pave-boot.service "$d/multi-user.target.wants/pave-boot.service" +} + +_pave_trigger_openrc_local() { + ensure_file "${PAVE_SYSROOT:-}/etc/local.d/pave.start" 755 "#!/bin/sh +exec $(_pave_payload "$1" boot)" +} + +_pave_trigger_rc_local() { + local f="${PAVE_SYSROOT:-}/etc/rc.local" + # `|| :` because some rc.local files run under `sh -e`, and pave's failures shouldn't stop the rest of it. + ensure_block "$f" top trigger "$(_pave_payload "$1" boot) || :" || return 1 + if [ ! -x "$f" ]; then + chmod +x -- "$f" || return 1 + changed "chmod +x $f" + fi +} + +_pave_trigger_cron_reboot() { + ensure_file "${PAVE_SYSROOT:-}/etc/cron.d/pave" 644 "@reboot root $(_pave_payload "$1" boot)" +} + +_pave_trigger_xdg_autostart() { + ensure_file "${XDG_CONFIG_HOME:-$HOME/.config}/autostart/pave-session.desktop" 644 "[Desktop Entry] +Type=Application +Name=pave session goals +Exec=$(_pave_payload "$1" session) +NoDisplay=true" +} + +## _pave_trigger_profile adds the login trigger to whichever file bash reads for login shells: +## the first of these that exists, else ~/.profile. +_pave_trigger_profile() { + local f + for f in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do + [ -e "$f" ] && break + done + ensure_block "$f" bottom trigger "$(_pave_payload "$1" login)" +} + +## _pave_resolve_goals prints the goals dir as an absolute path, keeping symlinks as given, +## so repointing a symlinked goals dir needs no reinstall. +_pave_resolve_goals() { + if ! (cd -- "$1" 2>/dev/null && pwd); then + _pave_error "goals dir '$1' not found" + return 1 + fi +} + +## _pave_cmd_install implements `pave install GOALS_DIR`: +## installs this user's triggers, then the boot trigger via sudo. +## +## - @return 0, or an exit code from the table in the README. +_pave_cmd_install() { + if [ "$#" -ne 1 ]; then + _pave_error "usage: pave install GOALS_DIR" + return 4 + fi + if [ "$EUID" -eq 0 ]; then + _pave_error "run pave install as the user whose session and login triggers it should install; it uses sudo for the boot trigger" + return 13 + fi + local dir + dir=$(_pave_resolve_goals "$1") || return 5 + _pave_trigger_path_ok "$PAVE_BIN" || return 9 + _pave_trigger_path_ok "$dir" || return 9 + + _PAVE_TALLY=/dev/null # install reports changes, but doesn't count them + local code=0 + _pave_trigger_xdg_autostart "$dir" || code=11 + _pave_trigger_profile "$dir" || code=11 + + echo "pave: installing the boot trigger, via sudo" + if ! sudo -- "$PAVE_BIN" install-boot "$dir"; then + _pave_error "sudo pave install-boot failed" + [ "$code" -ne 0 ] || code=12 + fi + return "$code" +} + +## _pave_cmd_install_boot implements `pave install-boot GOALS_DIR`: +## installs the boot trigger for this machine, then runs the boot phase, +## so its goals take effect now rather than at next boot. +## Normally called by `pave install`, via sudo. +## +## - @return 0, or an exit code from the table in the README. +_pave_cmd_install_boot() { + if [ "$#" -ne 1 ]; then + _pave_error "usage: pave install-boot GOALS_DIR" + return 4 + fi + local dir kind + dir=$(_pave_resolve_goals "$1") || return 5 + _pave_trigger_path_ok "$PAVE_BIN" || return 9 + _pave_trigger_path_ok "$dir" || return 9 + kind=$(_pave_detect_boot) || return 10 + + _PAVE_TALLY=/dev/null # install reports changes, but doesn't count them + echo "pave: boot trigger kind: $kind" + "_pave_trigger_${kind//-/_}" "$dir" || return 11 + "$PAVE_BIN" run "$dir" boot +} diff --git a/lib/knob.bash b/lib/knob.bash new file mode 100644 index 0000000..5eed1fd --- /dev/null +++ b/lib/knob.bash @@ -0,0 +1,24 @@ +## knob.bash: primitives for kernel settings files (sysfs, procfs). + +## write_knob writes a value into a kernel settings file. +## These files often read back in a different format than they accept +## (`lru_gen/enabled` takes `y` and reads back a hex bitmask), +## so a change is reported when the read-back moved, not by comparing it to VALUE. +## The write happens every time; these files reset at boot anyway, and rewriting a value is harmless. +## +## - @param $1 absolute path of the settings file. +## - @param $2 value to write. +write_knob() { + if [ "$#" -ne 2 ]; then + _pave_error "usage: write_knob PATH VALUE" + return 1 + fi + local path=$1 value=$2 before after + _pave_need_abs "$path" || return 1 + before=$(<"$path") || return 1 + printf '%s\n' "$value" >"$path" || return 1 + after=$(<"$path") || return 1 + if [ "$before" != "$after" ]; then + changed "$path: $before -> $after (wrote $value)" + fi +} diff --git a/lib/load.bash b/lib/load.bash new file mode 100644 index 0000000..fb7fdf4 --- /dev/null +++ b/lib/load.bash @@ -0,0 +1,8 @@ +## load.bash sources the whole pave library. +## Requires PAVE_ROOT: the directory containing lib/. + +source "$PAVE_ROOT/lib/report.bash" +source "$PAVE_ROOT/lib/run.bash" +source "$PAVE_ROOT/lib/fs.bash" +source "$PAVE_ROOT/lib/knob.bash" +source "$PAVE_ROOT/lib/install.bash" diff --git a/lib/report.bash b/lib/report.bash new file mode 100644 index 0000000..cb37ff6 --- /dev/null +++ b/lib/report.bash @@ -0,0 +1,45 @@ +## report.bash: how pave says what happened. +## +## Every run keeps a tally file: one 'c' per change and one 'f' per failure, +## appended by whichever shell (or subshell) observed it. +## A file rather than a variable, because chains and goal files run in subshells, +## and their counts have to survive back to the runner. + +## changed reports one act of convergence. +## Goal files may call it too, when converging by hand with plain commands. +## +## - @param $1... description of the change. +## - @stdout the report line. +changed() { + printf 'pave: changed: %s\n' "$*" + printf c >>"$_PAVE_TALLY" +} + +## _pave_error explains why something can't be converged. +## It's a reason, not a failure record: whoever then fails records that, with a location. +## +## - @param $1... the explanation. +## - @stderr the explanation. +_pave_error() { + >&2 printf 'pave: error: %s\n' "$*" +} + +## _pave_fail records one failure. +## +## - @param $1... what failed, and where. +## - @stderr the failure line. +_pave_fail() { + >&2 printf 'pave: FAIL: %s\n' "$*" + printf f >>"$_PAVE_TALLY" +} + +## _pave_count prints how many of one tally letter have been recorded. +## +## - @param $1 the letter: c or f. +## - @stdout the count. +_pave_count() { + local t='' + IFS= read -r t <"$_PAVE_TALLY" || : + t=${t//[^$1]/} + printf '%d' "${#t}" +} diff --git a/lib/run.bash b/lib/run.bash new file mode 100644 index 0000000..459138e --- /dev/null +++ b/lib/run.bash @@ -0,0 +1,181 @@ +## run.bash: `pave run`, and the goal file semantics it provides. + +PAVE_PHASES=(boot session login manual) + +## _pave_is_phase succeeds if $1 is a known phase name. +_pave_is_phase() { + local p + for p in "${PAVE_PHASES[@]}"; do + [ "$p" = "$1" ] && return 0 + done + return 1 +} + +## _pave_report_dir prints where run reports go. +## Root's reports go in /run, which is emptied every boot, +## so a missing boot report after boot means no boot trigger fired. +## Users' go in the XDG state dir, which (unlike the XDG runtime dir) always exists. +## +## - @stdout the directory path. +_pave_report_dir() { + if [ "$EUID" -eq 0 ]; then + printf '/run/pave' + else + printf '%s/pave' "${XDG_STATE_HOME:-$HOME/.local/state}" + fi +} + +## _pave_cmd_run implements `pave run GOALS_DIR PHASE`. +## The run's output (stdout and stderr together) goes both to stdout and to the report file, +## which appears only once the run is complete. +## A run that changed nothing and failed nothing prints nothing; its report still gets a summary. +## +## - @param $1 goals dir. +## - @param $2 phase. +## - @return 0, or an exit code from the table in the README. +_pave_cmd_run() { + if [ "$#" -ne 2 ]; then + _pave_error "usage: pave run GOALS_DIR PHASE" + return 4 + fi + local dir=$1 phase=$2 + if ! _pave_is_phase "$phase"; then + _pave_error "unknown phase '$phase' (phases are: ${PAVE_PHASES[*]})" + return 4 + fi + if ! dir=$(cd -- "$dir" 2>/dev/null && pwd); then + _pave_error "goals dir '$1' not found" + return 5 + fi + local rdir + rdir=$(_pave_report_dir) + if ! mkdir -p -- "$rdir"; then + _pave_error "cannot create report dir $rdir" + return 8 + fi + + local tmp="$rdir/$phase.report.$$" + _PAVE_TALLY="$rdir/.$phase.tally.$$" + : >"$_PAVE_TALLY" + local rc + # Not in a condition context: that would disable errexit inside chains. + set +e + (cd -- "$dir" && _pave_run_phase "$phase") </dev/null 2>&1 | tee -- "$tmp" + rc=${PIPESTATUS[0]} + set -e + + local nc nf summary + nc=$(_pave_count c) + nf=$(_pave_count f) + rm -f -- "$_PAVE_TALLY" + summary="pave: $phase: $nc changed, $nf failed (goals: $dir, host: $HOSTNAME)" + printf '%s\n' "$summary" >>"$tmp" + mv -- "$tmp" "$rdir/$phase.report" + if [ "$nc" -ne 0 ] || [ "$nf" -ne 0 ]; then + printf '%s\n' "$summary" + fi + return "$rc" +} + +## _pave_run_phase runs the common and then the host goal file for one phase. +## Expects to run with the goals dir as cwd, in a subshell of its own, with errexit off. +## +## - @param $1 phase. +## - @return 0; 6 if a file in the goals dir isn't named for a phase; else 7 if anything failed. +_pave_run_phase() { + local phase=$1 f name code=0 + + # Phase file names are checked in every run, so a misspelled one can't silently never run. + shopt -s nullglob + for f in ./*.bash ./hosts/*/*.bash; do + name=${f##*/} + _pave_is_phase "${name%.bash}" && continue + _pave_fail "$f: not named for a phase (phases are: ${PAVE_PHASES[*]})" + code=6 + done + shopt -u nullglob + + for f in "./$phase.bash" "./hosts/$HOSTNAME/$phase.bash"; do + [ -f "$f" ] || continue + _pave_run_file "$f" + done + + if [ "$code" -eq 0 ] && [ "$(_pave_count f)" -ne 0 ]; then + code=7 + fi + return "$code" +} + +## _pave_run_file sources one goal file, in a subshell of its own. +## Top-level commands run without errexit: a failure is recorded, and the file carries on. +## Things that abort the whole file (an unset variable, an `exit`) end only this subshell. +## A file with a syntax error doesn't run at all, rather than running up to the error. +## +## - @param $1 goal file path, relative to the goals dir. +_pave_run_file() { + local f=$1 rc + if ! bash -n -- "$f"; then + _pave_fail "$f: syntax error; skipped the whole file" + return 0 + fi + ( + _PAVE_FILE_SUBSHELL=$BASH_SUBSHELL + _PAVE_ACKED=$(_pave_count f) + _PAVE_CHAIN='' + set +e -Euo pipefail + trap _pave_on_err ERR + # shellcheck source=/dev/null + source "$f" + exit 0 + ) + # Deliberately not `( ... ) || rc=$?`: that's a condition context, which disables errexit inside chains. + rc=$? + if [ "$rc" -ne 0 ]; then + _pave_fail "$f: aborted (exit $rc); the rest of the file was skipped" + fi +} + +## _pave_on_err is the ERR trap for goal files. +## It records each failure once, at the deepest shell where it happened. +## A failure inside a subshell (a chain, a command substitution) also shows up +## as a failed subshell in every enclosing shell. +## Each shell knows how many failures it has accounted for; +## if the tally holds more, a deeper shell already recorded this one. +_pave_on_err() { + local rc=$? n + # `source` returns the status of the file's last command, which was already recorded. + [ "${FUNCNAME[1]}" = _pave_run_file ] && return + n=$(_pave_count f) + if [ "$n" -gt "$_PAVE_ACKED" ]; then + _PAVE_ACKED=$n + return + fi + _pave_fail "${_PAVE_CHAIN:+chain $_PAVE_CHAIN: }exit $rc at ${BASH_SOURCE[1]}:${BASH_LINENO[0]}" + _PAVE_ACKED=$((n + 1)) +} + +## chain makes the rest of its subshell stop at the first failing command. +## Use it as the first command of a parenthesized group: +## +## ( chain NAME +## step one +## step two +## ) +## +## A failed chain counts as one failure; the goal file carries on after it. +## Called outside a subshell, it refuses, since it would otherwise +## switch on errexit for the rest of the whole goal file. +## +## - @param $1 name, for reports. +chain() { + if [ "$BASH_SUBSHELL" -le "$_PAVE_FILE_SUBSHELL" ]; then + _pave_error "chain ${1:-}: not inside ( ... ); refusing to switch on errexit for the whole file" + return 1 + fi + if [ "$#" -ne 1 ] || [ -z "$1" ]; then + _pave_error "usage: ( chain NAME ... )" + exit 1 + fi + _PAVE_CHAIN=$1 + set -e +} diff --git a/tests/install.test b/tests/install.test new file mode 100755 index 0000000..3e4e044 --- /dev/null +++ b/tests/install.test @@ -0,0 +1,132 @@ +#!/bin/bash +## Tests for `pave install` and `pave install-boot`. +## +## Boot triggers go into a fake root (PAVE_SYSROOT), and a fake `sudo` on PATH just runs its command, +## so the whole install flow runs as the current user. +## Installed triggers are then actually executed, to prove their quoting works; +## the goals dir has a space in its name to make that mean something. + +# shellcheck source=tests/testlib.bash +source "${BASH_SOURCE[0]%/*}/testlib.bash" + +mkdir -p "$scratch/fakebin" +printf '#!/bin/bash\n[ "$1" = -- ] && shift\nexec "$@"\n' >"$scratch/fakebin/sudo" +chmod +x "$scratch/fakebin/sudo" +export PATH="$scratch/fakebin:$PATH" +export PAVE_SYSROOT="$scratch/root" + +G="$scratch/my goals" +M="$scratch/marks" +for phase in boot session login; do + goals "$G/$phase.bash" <<EOF +ensure_file "$M/$phase" 644 "\$PWD" +EOF +done + +## fresh_root empties the fake root and run marks, then creates the given dirs and files in the root. +## +## - @param $1... paths under the root; ending in / makes a dir, otherwise an empty file. +fresh_root() { + rm -rf -- "$PAVE_SYSROOT" "$M" + local p + for p in "$@"; do + if [[ $p == */ ]]; then + mkdir -p -- "$PAVE_SYSROOT/$p" + else + mkdir -p -- "$PAVE_SYSROOT/${p%/*}" + : >"$PAVE_SYSROOT/$p" + fi + done +} + +# ---- systemd, plus the user triggers; then idempotence and repointing ---- + +fresh_root run/systemd/system/ +pv install "$G" +assert_eq "systemd: exit 0" 0 "$rc" +assert_has "systemd: kind reported" "pave: boot trigger kind: systemd" +unit="$PAVE_SYSROOT/etc/systemd/system/pave-boot.service" +assert "systemd: unit has the payload" grep -qxF "ExecStart=\"$PAVE\" run \"$G\" boot" "$unit" +assert_eq "systemd: unit enabled" ../pave-boot.service \ + "$(readlink "$PAVE_SYSROOT/etc/systemd/system/multi-user.target.wants/pave-boot.service")" +assert_eq "install runs the boot phase" "$G" "$(<"$M/boot")" + +desktop="$HOME/.config/autostart/pave-session.desktop" +assert "xdg-autostart: desktop entry has the payload" grep -qxF "Exec=\"$PAVE\" run \"$G\" session" "$desktop" +assert "profile: block added to ~/.profile" grep -qxF "\"$PAVE\" run \"$G\" login" "$HOME/.profile" + +rm -rf -- "$M" +sh -c "$(sed -n 's/^ExecStart=//p' "$unit")" >/dev/null +assert_eq "systemd: ExecStart line runs the boot phase" "$G" "$(<"$M/boot")" +sh -c "$(sed -n 's/^Exec=//p' "$desktop")" >/dev/null +assert_eq "xdg-autostart: Exec line runs the session phase" "$G" "$(<"$M/session")" +sh -c ". \"\$HOME/.profile\"" >/dev/null +assert_eq "profile: sourcing it runs the login phase" "$G" "$(<"$M/login")" + +pv install "$G" +assert_eq "reinstall: exit 0" 0 "$rc" +assert_count "reinstall: changes nothing" "pave: changed" 0 + +G2="$scratch/other-goals" +goals "$G2/boot.bash" <<<'true' +pv install "$G2" +assert "repoint: unit follows the new goals dir" grep -qxF "ExecStart=\"$PAVE\" run \"$G2\" boot" "$unit" +assert_eq "repoint: one profile block, updated" 1 "$(grep -c '>>> pave:trigger >>>' "$HOME/.profile")" +assert "repoint: profile block follows" grep -qxF "\"$PAVE\" run \"$G2\" login" "$HOME/.profile" + +# ---- the login trigger goes where bash will read it ---- + +rm -f "$HOME/.profile" +: >"$HOME/.bash_profile" +pv install "$G" +assert "profile: ~/.bash_profile used when it exists" grep -qF 'pave:trigger' "$HOME/.bash_profile" +assert "profile: ~/.profile left alone then" test ! -e "$HOME/.profile" + +# ---- the other boot kinds ---- + +fresh_root etc/local.d/ +pv install "$G" +assert_has "openrc-local: kind reported" "pave: boot trigger kind: openrc-local" +start="$PAVE_SYSROOT/etc/local.d/pave.start" +assert_eq "openrc-local: executable" 755 "$(stat -c %a "$start")" +rm -rf -- "$M" +"$start" >/dev/null +assert_eq "openrc-local: script runs the boot phase" "$G" "$(<"$M/boot")" + +fresh_root etc/rc.local etc/cron.d/ +printf '#!/bin/sh -e\nexit 0\n' >"$PAVE_SYSROOT/etc/rc.local" +pv install "$G" +assert_has "rc-local: preferred over cron" "pave: boot trigger kind: rc-local" +assert_eq "rc-local: made executable" 755 "$(stat -c %a "$PAVE_SYSROOT/etc/rc.local")" +assert_eq "rc-local: block after the shebang, before exit" "#!/bin/sh -e" "$(head -n1 "$PAVE_SYSROOT/etc/rc.local")" +assert_eq "rc-local: exit stays last" "exit 0" "$(tail -n1 "$PAVE_SYSROOT/etc/rc.local")" +rm -rf -- "$M" +"$PAVE_SYSROOT/etc/rc.local" >/dev/null +assert_eq "rc-local: script runs the boot phase" "$G" "$(<"$M/boot")" + +fresh_root etc/cron.d/ +pv install "$G" +assert_has "cron-reboot: kind reported" "pave: boot trigger kind: cron-reboot" +assert "cron-reboot: entry has the payload" grep -qxF "@reboot root \"$PAVE\" run \"$G\" boot" "$PAVE_SYSROOT/etc/cron.d/pave" + +# ---- refusals ---- + +fresh_root +pv install-boot "$G" +assert_eq "no boot hook: install-boot exit 10" 10 "$rc" +assert_has "no boot hook: explained" "found no boot hook pave knows" +pv install "$G" +assert_eq "no boot hook: install exit 12" 12 "$rc" +assert "no boot hook: user triggers still installed" test -f "$desktop" + +fresh_root run/systemd/system/ +mkdir -p "$scratch/bad\$dir" +pv install "$scratch/bad\$dir" +assert_eq "unsafe path: exit 9" 9 "$rc" +assert "unsafe path: nothing installed" test ! -e "$unit" +pv install "$scratch/no-such-goals" +assert_eq "missing goals dir: exit 5" 5 "$rc" +pv install +assert_eq "usage: exit 4" 4 "$rc" + +verdict diff --git a/tests/primitives.test b/tests/primitives.test new file mode 100755 index 0000000..5a0e2b9 --- /dev/null +++ b/tests/primitives.test @@ -0,0 +1,176 @@ +#!/bin/bash +## Tests for the goal primitives: ensure_file, ensure_link, ensure_block, write_knob. +## Each is exercised through `pave run`, the way goal files use them. + +# shellcheck source=tests/testlib.bash +source "${BASH_SOURCE[0]%/*}/testlib.bash" + +G="$scratch/goals" +T="$scratch/t" +mkdir -p "$T" + +## converge writes a manual-phase goal file and runs it. +## +## - @stdin the goal file. +converge() { + goals "$G/manual.bash" + pv run "$G" manual +} + +# ---- ensure_file ---- + +converge <<EOF +ensure_file "$T/deep/er/f" 640 "line one +line two" +EOF +assert_has "ensure_file: creation reported" "pave: changed: created $T/deep/er/f" +assert_eq "ensure_file: content plus one newline" $'line one\nline two\n.' "$(exact "$T/deep/er/f")" +assert_eq "ensure_file: mode" 640 "$(stat -c %a "$T/deep/er/f")" +pv run "$G" manual +assert_eq "ensure_file: converged is quiet" "" "$out" + +printf 'line one\nline two\n\n' >"$T/deep/er/f" +pv run "$G" manual +assert_has "ensure_file: an extra trailing newline is drift" "pave: changed: rewrote $T/deep/er/f" +chmod 600 "$T/deep/er/f" +pv run "$G" manual +assert_has "ensure_file: mode drift fixed alone" "pave: changed: chmod 640 $T/deep/er/f (was 600)" +assert_count "ensure_file: mode drift doesn't rewrite" "rewrote" 0 + +mkdir "$T/adir" +ln -s elsewhere "$T/alink" +converge <<EOF +ensure_file "$T/adir" 644 x +ensure_file "$T/alink" 644 x +ensure_file relative/path 644 x +ensure_file "$T/badmode" rw x +ensure_file "$T/toofew" 644 +EOF +assert_eq "ensure_file refusals: exit 7" 7 "$rc" +assert_has "ensure_file: refuses a directory" "pave: error: $T/adir exists and is not a regular file" +assert_has "ensure_file: refuses a symlink" "pave: error: $T/alink exists and is not a regular file" +assert_has "ensure_file: refuses a relative path" "pave: error: 'relative/path' is not an absolute path" +assert_has "ensure_file: refuses a non-octal mode" "mode 'rw' is not octal" +assert_has "ensure_file: refuses wrong arg count" "usage: ensure_file PATH MODE CONTENT" +assert_count "ensure_file: each refusal recorded" "pave: FAIL" 5 +assert_eq "ensure_file: symlink untouched" elsewhere "$(readlink "$T/alink")" + +# ---- ensure_link ---- + +converge <<EOF +ensure_link ../target "$T/links/l" +EOF +assert_has "ensure_link: creation reported" "pave: changed: linked $T/links/l -> ../target" +assert_eq "ensure_link: exact target text" ../target "$(readlink "$T/links/l")" +pv run "$G" manual +assert_eq "ensure_link: converged is quiet" "" "$out" +ln -sfn /wrong "$T/links/l" +pv run "$G" manual +assert_has "ensure_link: repoint reported" "pave: changed: repointed $T/links/l -> ../target (was /wrong)" +assert_eq "ensure_link: repointed" ../target "$(readlink "$T/links/l")" + +touch "$T/plainfile" +converge <<EOF +ensure_link x "$T/plainfile" +EOF +assert_has "ensure_link: refuses a non-symlink" "pave: error: $T/plainfile exists and is not a symlink" +assert "ensure_link: non-symlink untouched" test -f "$T/plainfile" -a ! -L "$T/plainfile" + +# ---- ensure_block ---- + +B="$T/rc" +printf '#!/bin/sh -e\necho existing\n\nexit 0\n' >"$B" +chmod 751 "$B" +converge <<EOF +ensure_block "$B" top first "echo one +echo two" +EOF +assert_has "ensure_block: addition reported" "pave: changed: added block 'first' to $B" +assert_eq "ensure_block: top goes after the shebang" \ + $'#!/bin/sh -e\n# >>> pave:first >>>\necho one\necho two\n# <<< pave:first <<<\necho existing\n\nexit 0' "$(<"$B")" +assert_eq "ensure_block: mode kept" 751 "$(stat -c %a "$B")" +snap=$(exact "$B") +pv run "$G" manual +assert_eq "ensure_block: converged is quiet" "" "$out" +assert_eq "ensure_block: converged file byte-identical" "$snap" "$(exact "$B")" + +converge <<EOF +ensure_block "$B" top first "echo changed" +ensure_block "$B" bottom second "echo last" +EOF +assert_has "ensure_block: update reported" "pave: changed: updated block 'first' in $B" +assert_eq "ensure_block: updated in place, second at bottom, rest kept" \ + $'#!/bin/sh -e\n# >>> pave:first >>>\necho changed\n# <<< pave:first <<<\necho existing\n\nexit 0\n# >>> pave:second >>>\necho last\n# <<< pave:second <<<' "$(<"$B")" + +converge <<EOF +ensure_block "$B" top first "echo changed" +ensure_block "$B" top second "echo last" +EOF +assert_eq "ensure_block: moved to the requested end" \ + $'#!/bin/sh -e\n# >>> pave:second >>>\necho last\n# <<< pave:second <<<\n# >>> pave:first >>>\necho changed\n# <<< pave:first <<<\necho existing\n\nexit 0' "$(<"$B")" + +converge <<EOF +ensure_block "$T/fresh/rc" bottom b "x" +EOF +assert_eq "ensure_block: creates a missing file" $'# >>> pave:b >>>\nx\n# <<< pave:b <<<' "$(<"$T/fresh/rc")" +: >"$T/empty" +converge <<EOF +ensure_block "$T/empty" top b "x" +EOF +assert_eq "ensure_block: an empty file gains no blank line" $'# >>> pave:b >>>\nx\n# <<< pave:b <<<' "$(<"$T/empty")" + +printf 'keep\n# >>> pave:b >>>\nhalf a block\n' >"$T/corrupt" +converge <<EOF +ensure_block "$T/corrupt" top b "x" +ensure_block "$T/empty" middle b "x" +EOF +assert_has "ensure_block: refuses a block with no end marker" "block 'b' has a begin marker but no end marker" +assert_eq "ensure_block: corrupt file untouched" $'keep\n# >>> pave:b >>>\nhalf a block' "$(<"$T/corrupt")" +assert_has "ensure_block: refuses an unknown placement" "placement must be top or bottom" + +# ---- write_knob (against plain files; real sysfs can't be faked) ---- + +printf 'old\n' >"$T/knob" +converge <<EOF +write_knob "$T/knob" new +EOF +assert_has "write_knob: change reported with before and after" "pave: changed: $T/knob: old -> new (wrote new)" +pv run "$G" manual +assert_eq "write_knob: unchanged read-back is quiet" "" "$out" +converge <<EOF +write_knob "$T/no-such-knob" 1 +EOF +assert_eq "write_knob: missing file fails" 7 "$rc" + +# ---- primitives fail correctly even where errexit is off: in conditions ---- + +touch "$T/notadir" +converge <<EOF +if ensure_file "$T/notadir/f" 644 x; then echo WRONG-file; fi +if ensure_link x "$T/notadir/l"; then echo WRONG-link; fi +if ensure_block "$T/notadir/b" top b x; then echo WRONG-block; fi +if write_knob "$T/notadir/k" 1; then echo WRONG-knob; fi +( chain stops + ensure_file "$T/notadir/f" 644 x + echo WRONG-chain +) +EOF +assert_lacks "primitives: report failure inside conditions and chains" "WRONG" +assert_count "primitives: only the chain's failure is recorded" "pave: FAIL" 1 + +# An unreadable file is a failure where every later step would succeed, so only the guard can stop it. +# (Root reads anything, so this case only means something for other users.) +if [ "$EUID" -ne 0 ]; then + printf 'secret\n' >"$T/unreadable" + chmod 000 "$T/unreadable" + converge <<EOF +if ensure_file "$T/unreadable" 000 x; then echo WRONG-unreadable; fi +if ensure_block "$T/unreadable" top b x; then echo WRONG-unreadable; fi +EOF + assert_lacks "primitives: unreadable file fails, even in a condition" "WRONG" + assert_has "primitives: unreadable file explained" "pave: error: $T/unreadable is not readable" + chmod 600 "$T/unreadable" + assert_eq "primitives: unreadable file untouched" secret "$(<"$T/unreadable")" +fi + +verdict diff --git a/tests/run.test b/tests/run.test new file mode 100755 index 0000000..e57a767 --- /dev/null +++ b/tests/run.test @@ -0,0 +1,171 @@ +#!/bin/bash +## Tests for `pave run`: phase and host file selection, failure semantics, chains, reports. + +# shellcheck source=tests/testlib.bash +source "${BASH_SOURCE[0]%/*}/testlib.bash" + +# ---- file selection: common file, then this host's file, and no other host's ---- + +G="$scratch/select" +goals "$G/manual.bash" <<<'echo common-ran' +goals "$G/boot.bash" <<<'echo boot-ran' +goals "$G/hosts/testhost/manual.bash" <<<'echo host-ran' +goals "$G/hosts/otherhost/manual.bash" <<<'echo otherhost-ran' +pv run "$G" manual +assert_eq "selection: exit 0" 0 "$rc" +assert_eq "selection: common then host, nothing else" $'common-ran\nhost-ran' "$out" + +# ---- top-level failures are recorded and the file carries on ---- + +G="$scratch/toplevel" +goals "$G/manual.bash" <<'EOF' +false +echo after-first +x=$(exit 3) +echo after-cmdsubst +false +EOF +pv run "$G" manual +assert_eq "toplevel: exit 7" 7 "$rc" +assert_has "toplevel: failure located" "pave: FAIL: exit 1 at ./manual.bash:1" +assert_has "toplevel: carried on after failure" "after-first" +assert_has "toplevel: cmdsubst failure located" "pave: FAIL: exit 3 at ./manual.bash:3" +assert_has "toplevel: carried on after cmdsubst" "after-cmdsubst" +assert_has "toplevel: last-line failure located" "pave: FAIL: exit 1 at ./manual.bash:5" +assert_count "toplevel: each failure recorded once" "pave: FAIL" 3 +assert_has "toplevel: summary counts" "pave: manual: 0 changed, 3 failed" + +# ---- chains stop at their first failure; the file carries on after them ---- + +G="$scratch/chains" +goals "$G/manual.bash" <<'EOF' +( chain first + echo first-a + false + echo first-b-SHOULD-NOT-RUN +) +echo between +helper() { + echo helper-a + false + echo helper-b-SHOULD-NOT-RUN +} +( chain viafunc + helper + echo viafunc-SHOULD-NOT-RUN +) +inner() { + ( chain inner + false + ) +} +( chain outer + inner + echo outer-SHOULD-NOT-RUN +) +( chain ok + echo ok-a + true + echo ok-b +) +EOF +pv run "$G" manual +assert_eq "chains: exit 7" 7 "$rc" +assert_lacks "chains: nothing after a failure ran" "SHOULD-NOT-RUN" +assert_has "chains: failure named and located" "pave: FAIL: chain first: exit 1 at ./manual.bash:3" +assert_has "chains: file carried on" "between" +assert_has "chains: failure inside a called function stops the chain" "helper-a" +assert_has "chains: nested failure recorded at the innermost chain" "pave: FAIL: chain inner: exit 1" +assert_count "chains: each failed chain recorded once" "pave: FAIL" 3 +assert_has "chains: a chain that doesn't fail runs to the end" "ok-b" + +# ---- chain outside a subshell is refused, and doesn't switch on errexit ---- + +G="$scratch/badchain" +goals "$G/manual.bash" <<'EOF' +chain oops +false +echo still-running +( chain ) +EOF +pv run "$G" manual +assert_eq "bad chain: exit 7" 7 "$rc" +assert_has "bad chain: refusal explained" "chain oops: not inside ( ... )" +assert_has "bad chain: refusal recorded" "pave: FAIL: exit 1 at ./manual.bash:1" +assert_has "bad chain: errexit stayed off" "still-running" +assert_has "bad chain: nameless chain refused" "usage: ( chain NAME ... )" + +# ---- whole-file aborts: unset variables, syntax errors ---- + +G="$scratch/aborts" +goals "$G/manual.bash" <<'EOF' +echo before-unset +echo "$NO_SUCH_VARIABLE" +echo after-unset-SHOULD-NOT-RUN +EOF +goals "$G/hosts/testhost/manual.bash" <<'EOF' +echo syntax-SHOULD-NOT-RUN +if then fi +EOF +pv run "$G" manual +assert_eq "aborts: exit 7" 7 "$rc" +assert_has "aborts: ran up to the unset variable" "before-unset" +assert_lacks "aborts: nothing ran after an abort, or in a file with a syntax error" "SHOULD-NOT-RUN" +assert_has "aborts: unset variable aborts the file" "pave: FAIL: ./manual.bash: aborted" +assert_has "aborts: syntax error skips the file" "pave: FAIL: ./hosts/testhost/manual.bash: syntax error" + +# ---- usage errors, and misnamed phase files ---- + +pv run "$scratch/select" sesion +assert_eq "unknown phase argument: exit 4" 4 "$rc" +assert_has "unknown phase argument: lists phases" "boot session login manual" +pv run "$scratch/no-such-dir" manual +assert_eq "missing goals dir: exit 5" 5 "$rc" + +G="$scratch/misnamed" +goals "$G/manual.bash" <<<'echo manual-ran' +goals "$G/hosts/testhost/sesion.bash" <<<'true' +pv run "$G" manual +assert_eq "misnamed phase file: exit 6" 6 "$rc" +assert_has "misnamed phase file: named" "pave: FAIL: ./hosts/testhost/sesion.bash: not named for a phase" +assert_has "misnamed phase file: goals still ran" "manual-ran" + +# ---- the environment goal files run in ---- + +G="$scratch/env" +goals "$G/manual.bash" <<'EOF' +echo "cwd=$PWD" +if read -r line; then echo stdin-had-data; else echo stdin-empty; fi +EOF +out=$(echo surprise | "$PAVE" run "$G" manual 2>&1) +assert_has "env: cwd is the goals dir" "cwd=$G" +assert_has "env: stdin is empty" "stdin-empty" + +# ---- reports, and quiet convergence ---- + +G="$scratch/quiet" +T="$scratch/quiet-target" +goals "$G/manual.bash" <<EOF +ensure_file "$T" 644 hello +EOF +pv run "$G" manual +assert_has "report: first run reports the change" "pave: changed: created $T" +assert_has "report: first run prints a summary" "pave: manual: 1 changed, 0 failed" +pv run "$G" manual +assert_eq "report: converged run exits 0" 0 "$rc" +assert_eq "report: converged run prints nothing" "" "$out" +report="$HOME/.local/state/pave/manual.report" +assert "report: written under the XDG state dir" test -f "$report" +assert_eq "report: holds the summary even when quiet" \ + "pave: manual: 0 changed, 0 failed (goals: $G, host: testhost)" "$(<"$report")" +assert "report: no scratch files left behind" \ + test "$(cd "${report%/*}" && printf '%s ' .[!.]* *)" = ".[!.]* manual.report " + +XDG_STATE_HOME="$scratch/state" "$PAVE" run "$G" manual +assert "report: XDG_STATE_HOME honored" test -f "$scratch/state/pave/manual.report" + +pv run "$scratch/select" session +assert_eq "no goal files for a phase: exit 0" 0 "$rc" +assert_eq "no goal files for a phase: quiet" "" "$out" + +verdict diff --git a/tests/testlib.bash b/tests/testlib.bash new file mode 100644 index 0000000..bf758b0 --- /dev/null +++ b/tests/testlib.bash @@ -0,0 +1,88 @@ +## testlib.bash: shared scaffolding for pave's tests. +## +## Each test file sources this, then builds goal files in a scratch dir +## and runs the real `bin/pave` against them. +## HOME and HOSTNAME point into the scratch dir, so the real machine is never touched. + +set -euo pipefail + +PAVE=$(cd -P -- "${BASH_SOURCE[0]%/*}/.." && pwd)/bin/pave + +scratch=$(mktemp -d) +trap 'rm -rf "$scratch"' EXIT +export HOME="$scratch/home" +export HOSTNAME=testhost +unset XDG_STATE_HOME XDG_CONFIG_HOME +mkdir -p "$HOME" + +fails=0 + +## pv runs pave, capturing its combined output and exit status. +## +## - @param $1... arguments to pave. +## - @state out the combined stdout and stderr. +## - @state rc the exit status. +# shellcheck disable=SC2034 # rc is for the test files to read. +pv() { + rc=0 + out=$("$PAVE" "$@" 2>&1) || rc=$? +} + +## exact prints a file's content followed by a '.', so a command substitution can't hide trailing newlines. +exact() { + cat -- "$1" + printf . +} + +## goals writes a goal file, creating its directory. +## +## - @param $1 path of the goal file. +## - @stdin the file's content. +goals() { + mkdir -p -- "${1%/*}" + cat >"$1" +} + +_failed() { + >&2 printf 'FAIL: %s\n' "$1" + fails=$((fails + 1)) +} + +## assert checks that a command succeeds. +assert() { + local label=$1 + shift + "$@" || _failed "$label" +} + +## assert_eq checks two strings are equal. +assert_eq() { + [ "$2" = "$3" ] || _failed "$1 (want '$2', got '$3')" +} + +## assert_has checks that the last pv output contains a line with the given text. +assert_has() { + grep -qF -- "$2" <<<"$out" || _failed "$1 (no '$2' in output)" +} + +## assert_lacks checks that the last pv output doesn't contain the given text. +assert_lacks() { + ! grep -qF -- "$2" <<<"$out" || _failed "$1 (unwanted '$2' in output)" +} + +## assert_count checks how many lines of the last pv output contain the given text. +assert_count() { + local n + n=$(grep -cF -- "$2" <<<"$out" || :) + assert_eq "$1" "$3" "$n" +} + +## verdict reports the outcome and exits accordingly. +verdict() { + local name=${BASH_SOURCE[1]##*/} + if [ "$fails" -ne 0 ]; then + >&2 echo "$name: $fails case(s) failed." + exit 1 + fi + echo "$name: all cases passed." +}