## 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") &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 }