342 lines
16 KiB
Bash
342 lines
16 KiB
Bash
#!/usr/bin/env bash
|
|
# Generate the standalone kits: single-file versions of rig's own tools, one
|
|
# folder per profile, for machines the full rig is not going to.
|
|
#
|
|
# A kit is a pure function of rig as it is right now. It gains nothing rig lacks
|
|
# and loses nothing rig has — improve rig, regenerate, and every kit follows.
|
|
# Nothing in standalone/<profile>/ is ever edited by hand.
|
|
#
|
|
# What this file does NOT know, on purpose: which tools rig has, what they are
|
|
# called, how its libraries are split, where configuration lives or what it
|
|
# contains. Rig will change shape — scripts get split, renamed and grow new
|
|
# libraries — and a generator that encoded today's layout would quietly produce
|
|
# a wrong kit the first time it did. So this works from a contract a script opts
|
|
# into, and from nothing else:
|
|
#
|
|
# 1. A marker comment, alone on a line near the top, declares an entry point:
|
|
# (hash) rig:standalone <kit-name> <default-verb>
|
|
# The default verb must only REPORT: it is run as a smoke test.
|
|
# 2. Every `source` an entry point makes names a .sh file by a path that
|
|
# resolves relative to the entry point. Libraries may source further
|
|
# libraries however they like — bash follows those itself.
|
|
# 3. Configuration enters through `load_config`, and the libraries provide
|
|
# `config_profiles` and `config_freeze <profile>` — the latter prints a
|
|
# replacement load_config with that profile resolved. How config is layered,
|
|
# stored, derived or frozen is rig's business; this only asks, and embeds
|
|
# the answer without interpreting it.
|
|
#
|
|
# Bash does the resolving, not a parser here. Libraries are sourced in a clean
|
|
# shell and read back with `declare -f` and `declare -p`, so any structure bash
|
|
# can load, this can flatten.
|
|
#
|
|
# And every kit is PROVEN to stand alone before it is written: no `source` left,
|
|
# no path into rig's tree in its code, `bash -n` clean, and its default verb run
|
|
# in an empty directory with nothing from rig present. A shape this has never
|
|
# seen either passes that, or generation stops and names the kit, the file, the
|
|
# line and what is wrong. It never writes a kit that only looks finished.
|
|
#
|
|
# Usage:
|
|
# standalone.sh write generate every kit into standalone/<profile>/
|
|
# standalone.sh check generate into a scratch dir and fail if any kit differs
|
|
set -euo pipefail
|
|
cd "$(dirname "$0")"
|
|
|
|
CTRL="$PWD"
|
|
ROOT="$(cd .. && pwd)"
|
|
OUT="$ROOT/standalone"
|
|
SELF_REL="ctrl/${0##*/}"
|
|
|
|
GENERATED_TAG="GENERATED by make standalone — do not edit"
|
|
FROZEN_OPEN="# ── configuration, frozen"
|
|
FROZEN_CLOSE="# ── end of frozen configuration"
|
|
|
|
refuse() { echo >&2; echo "standalone: refusing — $*" >&2; exit 1; }
|
|
|
|
# A clean bash with nothing from the caller's shell in it. What the kit carries
|
|
# must not depend on who ran the generator or what they had exported.
|
|
clean_bash() { env -i PATH="$PATH" HOME="$HOME" bash --noprofile --norc "$@"; }
|
|
|
|
# ── 1. entry points ────────────────────────────────────────────────────────
|
|
entries() {
|
|
grep -rlE --include='*.sh' '^# rig:standalone [a-z0-9-]+ [a-z0-9-]+' . 2>/dev/null \
|
|
| sed 's|^\./||' | LC_ALL=C sort
|
|
}
|
|
marker_of() { # entry -> "kit verb"
|
|
sed -nE 's/^# rig:standalone ([a-z0-9-]+) ([a-z0-9-]+).*/\1 \2/p' "$1" | head -1
|
|
}
|
|
|
|
# ── 2. the libraries an entry point sources ────────────────────────────────
|
|
# Only the entry point's own `source` lines are read as text. Everything those
|
|
# libraries pull in is resolved by bash when they are sourced in step 3.
|
|
libs_of() { # entry -> one resolved lib path per line, relative to ctrl/
|
|
local entry="$1" dir line n path
|
|
dir=$(dirname "$entry")
|
|
while IFS=: read -r n line; do
|
|
path=$(printf '%s' "$line" | sed -E 's/^[[:space:]]*(source|\.)[[:space:]]+//; s/[[:space:]]+(#.*)?$//')
|
|
path=${path#\"}; path=${path%\"}; path=${path#\'}; path=${path%\'}
|
|
case "$path" in
|
|
*'$'*) refuse "$entry:$n sources '$path' — a path with a variable in it cannot be resolved; name the file" ;;
|
|
esac
|
|
case "$path" in
|
|
*.sh) ;;
|
|
*) refuse "$entry:$n sources '$path' directly — only libraries (.sh) may be sourced; configuration has to enter through load_config" ;;
|
|
esac
|
|
path="$dir/${path#./}"; path=${path#./}
|
|
[ -f "$path" ] || refuse "$entry:$n sources '$path', which does not exist"
|
|
printf '%s\n' "$path"
|
|
done < <(grep -nE '^[[:space:]]*(source|\.)[[:space:]]+[^=]' "$entry" || true)
|
|
}
|
|
|
|
# Into the global array `libs`. Not `mapfile < <(libs_of ...)`: a refusal inside
|
|
# a process substitution only ends that subshell, so generation would carry on
|
|
# past it and fail later with a message about something else entirely.
|
|
libs_into() {
|
|
local out
|
|
out=$(libs_of "$1") || exit 1
|
|
libs=()
|
|
[ -n "$out" ] && mapfile -t libs <<< "$out"
|
|
return 0
|
|
}
|
|
|
|
# ── 3. what the libraries define, read back from bash itself ───────────────
|
|
# The frozen config replaces load_config, and the generator's own two questions
|
|
# are useless inside a kit, so none of the three is carried.
|
|
lib_defs() { # entry lib... -> declare -p globals, then declare -f functions
|
|
local entry="$1"; shift
|
|
( cd "$(dirname "$entry")" && clean_bash -c '
|
|
skip_var() { case "$1" in BASH*|FUNCNAME|PIPESTATUS|LINENO|RANDOM|SRANDOM|SECONDS|EPOCH*|HISTCMD|COLUMNS|LINES|PWD|OLDPWD|_|SHLVL|OPTIND|OPTERR|IFS|PS4|PATH|HOME|v|f|l|before_v|before_f) return 0 ;; esac; return 1; }
|
|
before_v=" $(compgen -v | tr "\n" " ") "
|
|
before_f=" $(compgen -A function | tr "\n" " ") "
|
|
for l in "$@"; do source "$l" || { echo "__FAIL__ sourcing $l" ; exit 1; }; done
|
|
for v in $(compgen -v); do
|
|
skip_var "$v" && continue
|
|
case "$before_v" in *" $v "*) continue ;; esac
|
|
declare -p "$v"
|
|
done
|
|
for f in $(compgen -A function); do
|
|
case "$before_f" in *" $f "*) continue ;; esac
|
|
case "$f" in skip_var|load_config|config_profiles|config_snapshot|config_freeze) continue ;; esac
|
|
declare -f "$f"
|
|
done
|
|
' _ "$@" ) || refuse "$entry: its libraries could not be sourced cleanly"
|
|
}
|
|
|
|
# ── 4. ask rig for profiles and resolved config ────────────────────────────
|
|
ask() { # entry lib... -- function args... -> that function's stdout
|
|
local entry="$1"; shift
|
|
local libs=() a
|
|
while [ $# -gt 0 ] && [ "$1" != -- ]; do libs+=("$1"); shift; done
|
|
shift
|
|
( cd "$(dirname "$entry")" && clean_bash -c '
|
|
n=0; for a in "$@"; do n=$((n+1)); [ "$a" = -- ] && break; done
|
|
for l in "${@:1:$((n-1))}"; do source "$l"; done
|
|
shift "$n"
|
|
declare -F "$1" >/dev/null || exit 3
|
|
"$@"
|
|
' _ "${libs[@]}" -- "$@" )
|
|
}
|
|
|
|
# ── 5. assemble one kit file ───────────────────────────────────────────────
|
|
assemble() { # entry profile out-file lib...
|
|
local entry="$1" profile="$2" dest="$3"; shift 3
|
|
local libs=("$@") calls_config=no
|
|
grep -qE '(^|[^A-Za-z0-9_])load_config([^A-Za-z0-9_]|$)' "$entry" && calls_config=yes
|
|
|
|
{
|
|
echo '#!/usr/bin/env bash'
|
|
echo "# $GENERATED_TAG"
|
|
echo "#"
|
|
echo "# $(basename "$dest") for profile '$profile', flattened from:"
|
|
echo "# ctrl/$entry"
|
|
local l; for l in ${libs[@]+"${libs[@]}"}; do echo "# ctrl/$l"; done
|
|
echo "# Edit those and run \`make standalone\`. Changes made here are lost, and"
|
|
echo "# \`make selftest\` fails while this file differs from what rig generates."
|
|
echo
|
|
|
|
if [ ${#libs[@]} -gt 0 ]; then
|
|
echo "# ── from the libraries ──"
|
|
lib_defs "$entry" "${libs[@]}"
|
|
echo
|
|
fi
|
|
|
|
if [ "$calls_config" = yes ]; then
|
|
local frozen
|
|
frozen=$(ask "$entry" ${libs[@]+"${libs[@]}"} -- config_freeze "$profile") \
|
|
|| refuse "ctrl/$entry calls load_config, but its libraries do not answer config_freeze for '$profile'"
|
|
echo "$FROZEN_OPEN for profile '$profile' ──"
|
|
printf '%s\n' "$frozen"
|
|
echo "$FROZEN_CLOSE ──"
|
|
echo
|
|
fi
|
|
|
|
echo "# ── ctrl/$entry ──"
|
|
# The entry point itself, minus its shebang and marker, with each source
|
|
# line it made replaced by a note — what it sourced is already above.
|
|
awk '
|
|
NR == 1 && /^#!/ { next }
|
|
/^# rig:standalone / { next }
|
|
/^[[:space:]]*(source|\.)[[:space:]]+[^=]/ { print "# (sourced library inlined above)"; next }
|
|
{ print }
|
|
' "$entry"
|
|
} > "$dest"
|
|
chmod +x "$dest"
|
|
}
|
|
|
|
# ── 6. the kit's Makefile, from the markers ────────────────────────────────
|
|
verbs_of() { # entry -> its top-level dispatch arms
|
|
awk '/^case / { inb=1; next } /^esac/ { inb=0 } inb && match($0, /^ [a-z][a-z-]*\)/) { v=substr($0, 5, RLENGTH-5); printf "%s%s", (n++ ? "|" : ""), v }' "$1"
|
|
}
|
|
makefile() { # out-dir entry...
|
|
local dir="$1"; shift
|
|
local e kit verb target verbs targets=""
|
|
for e in "$@"; do targets+=" $(basename "$e" .sh)"; done
|
|
{
|
|
echo "# $GENERATED_TAG"
|
|
echo "#"
|
|
echo "# Shorthand for the scripts beside it; they run without it. Every target"
|
|
echo "# calls a verb its script accepts — read from that script's own dispatch."
|
|
echo
|
|
echo 'HERE := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))'
|
|
echo 'ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))'
|
|
echo 'ifneq ($(ARGS),)'
|
|
echo '$(eval $(ARGS):;@:)'
|
|
echo '.PHONY: $(ARGS)'
|
|
echo 'endif'
|
|
echo
|
|
echo '.DEFAULT_GOAL := help'
|
|
echo ".PHONY: help$targets"
|
|
echo
|
|
echo 'help: ## list targets'
|
|
printf '\t%s\n' "@grep -hE '^[a-z][a-z-]*:.*?##' \$(MAKEFILE_LIST) | sed 's/:.*##/\\t/' | expand -t16"
|
|
for e in "$@"; do
|
|
read -r kit verb <<< "$(marker_of "$e")"
|
|
target=$(basename "$e" .sh)
|
|
verbs=$(verbs_of "$e")
|
|
echo
|
|
printf '%-30s ## %s.sh [%s] (default %s)\n' "$target:" "$kit" "${verbs:-?}" "$verb"
|
|
printf '\tbash $(HERE)%s.sh $(or $(ARGS),%s)\n' "$kit" "$verb"
|
|
done
|
|
} > "$dir/Makefile"
|
|
}
|
|
|
|
# ── 7. prove a kit stands alone ────────────────────────────────────────────
|
|
verify_kit() { # dir profile entry...
|
|
local dir="$1" profile="$2"; shift 2
|
|
local e kit verb f bad smoke rc
|
|
for e in "$@"; do
|
|
read -r kit verb <<< "$(marker_of "$e")"
|
|
f="$dir/$kit.sh"
|
|
|
|
bash -n "$f" 2>/dev/null || refuse "$profile/$kit.sh does not parse: $(bash -n "$f" 2>&1 | head -1)"
|
|
|
|
# Code only: comments are free to mention anything, and the frozen block
|
|
# is data — a value that happens to hold a path is harmless unless code
|
|
# opens it, and opening it is what the smoke run below would catch.
|
|
bad=$(awk -v fz_open="$FROZEN_OPEN" -v fz_close="$FROZEN_CLOSE" '
|
|
index($0, fz_open) == 1 { fz=1; next }
|
|
index($0, fz_close) == 1 { fz=0; next }
|
|
fz || /^[[:space:]]*#/ { next }
|
|
/^[[:space:]]*(source|\.)[[:space:]]+[^=]/ { printf "%d: still sources: %s\n", NR, $0; next }
|
|
# Rig-relative only. The preceding character may not be "/", so an
|
|
# absolute system path such as /var/lib/docker is not mistaken for
|
|
# rig lib/; an explicit ./ or ../ prefix is matched on its own.
|
|
/(^|[^A-Za-z0-9_.\/])(ctrl\/|lib\/|env\.d\/)|\.\.?\/(ctrl\/|lib\/|env\.d\/)|versions\.env|(^|[^A-Za-z0-9_])\.env([^A-Za-z0-9_]|$)/ {
|
|
printf "%d: refers into rig'"'"'s tree: %s\n", NR, $0
|
|
}' "$f" | head -3)
|
|
[ -z "$bad" ] || refuse "$profile/$kit.sh does not stand alone —"$'\n'"$(printf '%s\n' "$bad" | sed 's/^/ line /')"
|
|
done
|
|
|
|
# The real test: a folder holding only this kit, and nothing else from rig.
|
|
smoke=$(mktemp -d)
|
|
cp "$dir"/* "$smoke"/
|
|
for e in "$@"; do
|
|
read -r kit verb <<< "$(marker_of "$e")"
|
|
rc=0
|
|
out=$( (cd "$smoke" && timeout 120 bash "./$kit.sh" "$verb") 2>&1 ) || rc=$?
|
|
if [ "$rc" -ne 0 ]; then
|
|
rm -rf "$smoke"
|
|
refuse "$profile/$kit.sh $verb exits $rc in an empty directory:"$'\n'"$(printf '%s\n' "$out" | tail -5 | sed 's/^/ /')"
|
|
fi
|
|
done
|
|
( cd "$smoke" && make -s help >/dev/null ) || { rm -rf "$smoke"; refuse "$profile/Makefile: make help fails"; }
|
|
rm -rf "$smoke"
|
|
}
|
|
|
|
# ── generate ───────────────────────────────────────────────────────────────
|
|
generate() { # into-dir
|
|
local into="$1" e profiles="" p kit verb libs
|
|
local -a all_entries=()
|
|
while IFS= read -r e; do all_entries+=("$e"); done < <(entries)
|
|
[ ${#all_entries[@]} -gt 0 ] || refuse "no script under ctrl/ carries a '# rig:standalone <kit> <verb>' marker"
|
|
|
|
# Profiles come from whichever entry point's libraries can answer for them.
|
|
for e in "${all_entries[@]}"; do
|
|
libs_into "$e"
|
|
profiles=$(ask "$e" ${libs[@]+"${libs[@]}"} -- config_profiles 2>/dev/null) && [ -n "$profiles" ] && break
|
|
profiles=""
|
|
done
|
|
[ -n "$profiles" ] || refuse "no entry point's libraries answer config_profiles, so there is nothing to generate a kit per"
|
|
|
|
for p in $profiles; do
|
|
mkdir -p "$into/$p"
|
|
for e in "${all_entries[@]}"; do
|
|
read -r kit verb <<< "$(marker_of "$e")"
|
|
libs_into "$e"
|
|
assemble "$e" "$p" "$into/$p/$kit.sh" ${libs[@]+"${libs[@]}"}
|
|
done
|
|
makefile "$into/$p" "${all_entries[@]}"
|
|
verify_kit "$into/$p" "$p" "${all_entries[@]}"
|
|
echo " $p: $(cd "$into/$p" && ls | tr '\n' ' ')"
|
|
done
|
|
}
|
|
|
|
# A kit folder is ours if its Makefile says so. Anything else under standalone/
|
|
# is left alone, so a hand-written file there is never swept away.
|
|
is_generated_dir() { grep -qF "$GENERATED_TAG" "$1/Makefile" 2>/dev/null; }
|
|
|
|
cmd="${1:-write}"
|
|
[ $# -gt 0 ] && shift
|
|
|
|
case "$cmd" in
|
|
write)
|
|
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
echo "generating kits from rig's current tree"
|
|
generate "$tmp"
|
|
mkdir -p "$OUT"
|
|
for d in "$OUT"/*/; do
|
|
d=${d%/}; [ -d "$d" ] || continue
|
|
if is_generated_dir "$d" && [ ! -d "$tmp/${d##*/}" ]; then
|
|
echo " removed $(basename "$d") — no such profile any more"
|
|
rm -rf "$d"
|
|
fi
|
|
done
|
|
for d in "$tmp"/*/; do
|
|
d=${d%/}
|
|
rm -rf "$OUT/${d##*/}"
|
|
cp -r "$d" "$OUT/${d##*/}"
|
|
done
|
|
echo "wrote standalone/<profile>/ — every kit verified to stand alone"
|
|
;;
|
|
check)
|
|
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
generate "$tmp" >/dev/null
|
|
stale=0
|
|
for d in "$tmp"/*/; do
|
|
d=${d%/}; p=${d##*/}
|
|
if ! diff -rq "$d" "$OUT/$p" >/dev/null 2>&1; then
|
|
echo "stale: standalone/$p — $(diff -rq "$d" "$OUT/$p" 2>&1 | head -1)"
|
|
stale=1
|
|
fi
|
|
done
|
|
for d in "$OUT"/*/; do
|
|
d=${d%/}; [ -d "$d" ] || continue
|
|
if is_generated_dir "$d" && [ ! -d "$tmp/${d##*/}" ]; then
|
|
echo "stale: standalone/${d##*/} — no such profile any more"; stale=1
|
|
fi
|
|
done
|
|
[ "$stale" -eq 0 ] || { echo "run: make standalone"; exit 1; }
|
|
echo "every kit is current"
|
|
;;
|
|
*) echo "usage: $SELF_REL [write|check]" >&2; exit 1 ;;
|
|
esac
|