Files
soleprint/rig/ctrl/standalone.sh
2026-09-16 19:08:46 -03:00

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