pave

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