#!/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// 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 # 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 ` — 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// # 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 ' 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// — 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