run.bash
## 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
}
lib/run.bash · 5.4 KiB · 7240887182