Files
soleprint/rig/ctrl/selftest.sh
2026-09-22 08:18:02 -03:00

399 lines
21 KiB
Bash
Executable File

#!/usr/bin/env bash
# What rig has settled, written down as assertions: one decision per check.
# No cluster, docker or network; exits 1 on failure (unlike `make check`).
# Usage: make selftest [install] (or: bash ctrl/selftest.sh [install])
# install: the installer in clean containers — docker, network, minutes (installtest.sh)
# Notes: docs/notes/selftest.md
set -uo pipefail # NOT -e: one failing check must not abort the rest
cd "$(dirname "$0")"
if [ "${1:-}" = install ]; then shift; exec bash ./installtest.sh "$@"; fi
source ./lib/config.sh
rc=0
passed=0
check() { # name, expected, actual
if [ "$2" = "$3" ]; then
printf ' ok %s\n' "$1"
passed=$((passed + 1))
else
printf ' FAIL %s\n expected: %s\n got: %s\n' "$1" "$2" "$3"
rc=1
fi
}
note() { printf '\n%s\n' "$1"; }
# A scratch copy of rig for a check to change freely. local/ (overlays, possibly
# someone else's) and def/ (scratch) never ride along, and neither do this
# machine's PROFILE/OVERLAY/CLUSTER choices: a check sets what it tests.
copy_rig() { # dest-dir
mkdir -p "$1"
tar -C .. --exclude=./local --exclude=./def -cf - . | tar -C "$1" -xf -
if [ -f "$1/ctrl/.env" ]; then
sed -i '/^PROFILE=/d; /^OVERLAY=/d; /^CLUSTER=/d; /^MANIFESTS_DIR=/d' "$1/ctrl/.env"
fi
}
# Resolve one key the way every rig script does, in a clean shell so the
# caller's exported value is the only thing in play.
resolved() {
bash -c 'source ./lib/config.sh; load_config >/dev/null 2>&1; printf "%s" "${!1}"' _ "$1"
}
note "rig needs no profile"
# No env.d/ must still resolve and generate a kit; an unknown profile stays an error.
NP="$(mktemp -d)"
copy_rig "$NP/rig"; rm -rf "$NP/rig/ctrl/env.d"
check "no env.d: config resolves" "default" \
"$(cd "$NP/rig/ctrl" && bash -c 'source ./lib/config.sh; load_config >/dev/null && echo "$PROFILE_NAME"' 2>&1)"
check "no env.d: the k8s version comes from the pins" "yes" \
"$(cd "$NP/rig/ctrl" && bash -c 'source ./lib/config.sh; load_config >/dev/null && [ -n "$NODE_IMAGE" ] && echo yes' 2>&1)"
check "no env.d: ports.sh active works" "8" \
"$(cd "$NP/rig/ctrl" && bash ports.sh active 2>/dev/null | wc -w)"
check "no env.d: a kit is generated for the defaults" "yes" \
"$( (cd "$NP/rig/ctrl" && rm -rf ../standalone/*/ && bash standalone.sh write >/dev/null 2>&1) && [ -f "$NP/rig/standalone/default/rigdeps.sh" ] && echo yes || echo no)"
check "a profile that does not exist is still an error" "yes" \
"$( (cd "$NP/rig/ctrl" && PROFILE=no-such-profile bash -c 'source ./lib/config.sh; load_config' >/dev/null 2>&1) && echo no || echo yes)"
rm -rf "$NP"
note "the ports.sh active contract"
# ports.sh active is read positionally by the Makefile and Tiltfile: pin field count and order.
FACTS="$(bash ports.sh active)"
check "active: exactly 8 fields" "8" "$(printf '%s' "$FACTS" | wc -w)"
read -r F_CLUSTER F_CTX F_HTTP F_HTTPS F_TILT F_REG F_MANIFESTS F_OVERLAY <<< "$FACTS"
check "active: field 2 is kind-<cluster>" "kind-$F_CLUSTER" "$F_CTX"
check "active: fields 3-6 are numeric" "yes" \
"$([[ "$F_HTTP$F_HTTPS$F_TILT$F_REG" =~ ^[0-9]+$ ]] && echo yes || echo no)"
# Absolute, or - when there is none: an empty field would shift every later one.
check "active: fields 7-8 are absolute paths or -" "yes" \
"$(for f in "$F_MANIFESTS" "$F_OVERLAY"; do case "$f" in -|/*) ;; *) echo no; exit; esac; done; echo yes)"
# derive answers a different question and must keep its own shape: it reports
# what the directory name implies, ignoring ctrl/.env, so nothing should
# configure itself from it.
check "derive: still 4 fields, not 7" "4" "$(bash ports.sh derive | wc -w)"
note "the caller's env beats the files"
# Every key in CONFIG_OVERRIDABLE must lose to the caller's env; the loop follows the list.
test_value() {
case "$1" in
# Picked from what exists, never named: rig must not need any particular
# profile, template or pinned version to be present for this to run.
PROFILE) config_profiles | head -1 ;;
K8S_VERSION) (set -a; source ./versions.env; compgen -v NODE_IMAGE_v | sort -V | head -1 | sed 's/^NODE_IMAGE_//') ;;
# An absolute path, as a project passing its own file does. Never equal
# to the default, so the check cannot pass by accident.
KIND_CONFIG) echo "$PWD/k8s/kind-config.yaml.tpl" ;;
*_PORT) echo "19999" ;;
CLUSTER) echo "selftest-name" ;;
# Named folders must exist, and must not be the default.
MANIFESTS_DIR) echo "examples/starter/k8s/base" ;;
OVERLAY) echo "examples/data" ;;
ADDONS) echo "metallb" ;;
*) echo "selftest-sentinel" ;;
esac
}
for key in $CONFIG_OVERRIDABLE; do
[ -n "$key" ] || continue
want="$(test_value "$key")"
if [ -z "$want" ]; then
check "precedence: $key has a test value" "yes" "no — add one to test_value()"
continue
fi
got="$(export "$key=$want"; resolved "$key")"
check "precedence: caller's $key wins" "$want" "$got"
done
note "one derivation, not three"
# The Makefile must take context/port from ports.sh active, checked on real `make -n` output.
# --no-print-directory + grep, not tail -1: under `make selftest` this is a recursive make.
MK="$(cd .. && make --no-print-directory -n tilt 2>/dev/null | grep -m1 'tilt ')"
check "Makefile: --context comes from active" "$F_CTX" \
"$(printf '%s' "$MK" | sed -n 's/.*--context \([^ ]*\).*/\1/p')"
check "Makefile: --port comes from active" "$F_TILT" \
"$(printf '%s' "$MK" | sed -n 's/.*--port \([^ ]*\).*/\1/p')"
note "identity follows the folder, safely"
# The cluster name is the folder name made a DNS label, derived only in lib/config.sh.
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
mkdir -p "$TMP/My_Proj"
cp -r . "$TMP/My_Proj/ctrl"
# A pinned CLUSTER in .env would be an override, not a derivation, and this
# check is about the derivation. (An OVERLAY would be another derivation.)
sed -i '/^CLUSTER=/d; /^OVERLAY=/d' "$TMP/My_Proj/ctrl/.env" 2>/dev/null
COPY="$(cd "$TMP/My_Proj/ctrl" && bash ports.sh active)"
check "a dir named My_Proj derives a DNS label" "my-proj" "$(awk '{print $1}' <<< "$COPY")"
check "and a context to match" "kind-my-proj" "$(awk '{print $2}' <<< "$COPY")"
check "a renamed copy gets a DIFFERENT block" "different" \
"$([ "$(awk '{print $3}' <<< "$COPY")" != "$F_HTTP" ] && echo different || echo COLLIDES)"
note "ports are stable across versions"
# Ports are derived, never stored: a changed derivation moves every existing env's ports.
check "derive_port_base rig" "20310" "$(derive_port_base rig)"
check "derive_port_base foo" "21690" "$(derive_port_base foo)"
check "derive_port_base my-proj" "21030" "$(derive_port_base my-proj)"
note "rig stays standalone"
# rig must be copyable out of its host project: no references to the host.
# The pattern is assembled from fragments so this file does not match itself.
# The host project's word for a backing service counts too: rig described its
# workload addons with it until they left. local/ holds overlays, which may say anything.
HOST_PAT="$(printf '%s' 'sole' 'print' '|\b' 'sp' 'r\b' '|' 'cab' 'inet')"
check "no host-project references" "0" \
"$(cd .. && grep -rIl -iE "$HOST_PAT" . --exclude-dir=def --exclude-dir=local 2>/dev/null | wc -l)"
note "what runs is an overlay; rig only reads it"
# docs/notes/overlay.md. Every check runs in a scratch copy with its own overlay.
OV="$TMP/overlay-proof"; copy_rig "$OV/rig"
OVR="$OV/rig"
mkdir -p "$OVR/local/My_Env/addons" "$OVR/local/My_Env/k8s/prod" "$OVR/ctrl/env.d"
printf 'ADDONS="from-profile"\nDATA_NAMESPACE=from-profile\n' > "$OVR/ctrl/env.d/selftest.env"
cat > "$OVR/local/My_Env/rig.env" <<'EOF'
ADDONS="metallb"
DATA_NAMESPACE=from-overlay
MANIFESTS_DIR=k8s/prod
SELFTEST_SENTINEL=selftest-overlay-sentinel
EOF
printf 'resources: []\n' > "$OVR/local/My_Env/k8s/prod/kustomization.yaml"
printf '#!/usr/bin/env bash\necho "overlay-metallb from $PWD with ${RIG_CTRL:-no RIG_CTRL}"\n' \
> "$OVR/local/My_Env/addons/metallb.sh"
in_ov() { (cd "$OVR/ctrl" && "$@"); }
ov_key() { # key [env assignments...]
local k="$1"; shift
in_ov env "$@" bash -c 'source ./lib/config.sh; load_config >/dev/null 2>&1; printf "%s" "${!1}"' _ "$k"
}
# With nothing named, rig behaves as it did before overlays: same name, ports, addons, nodes.
check "no overlay: the cluster, ports and addons of before" "rig kind-rig 20310 20311 20312 20313" \
"$(in_ov bash ports.sh active | awk '{print $1, $2, $3, $4, $5, $6}')"
check "no overlay: no addons, one node, rig's own kind config" "|1|./k8s/kind-config.yaml.tpl" \
"$(ov_key ADDONS)|$(ov_key NODES)|$(ov_key KIND_CONFIG)"
# The overlay's rig.env sits between the profile and ctrl/.env; the caller beats all.
check "rig.env beats the profile" "from-overlay" \
"$(ov_key DATA_NAMESPACE PROFILE=selftest OVERLAY=local/My_Env)"
echo 'DATA_NAMESPACE=from-dotenv' >> "$OVR/ctrl/.env"
check "ctrl/.env beats rig.env" "from-dotenv" \
"$(ov_key DATA_NAMESPACE PROFILE=selftest OVERLAY=local/My_Env)"
sed -i '/^DATA_NAMESPACE=from-dotenv$/d' "$OVR/ctrl/.env"
check "the caller beats rig.env" "from-caller" \
"$(ov_key ADDONS OVERLAY=local/My_Env ADDONS=from-caller)"
# Identity follows the overlay's folder, so one rig serves several without collisions.
check "identity follows the overlay's folder" "my-env kind-my-env" \
"$(in_ov env OVERLAY=local/My_Env bash ports.sh active | awk '{print $1, $2}')"
check "paths in rig.env are relative to the overlay" "$OVR/local/My_Env/k8s/prod" \
"$(in_ov env OVERLAY=local/My_Env bash ports.sh active | awk '{print $7}')"
check "a named overlay that does not exist is an error" "yes" \
"$(in_ov env OVERLAY=local/nope bash ports.sh active >/dev/null 2>&1 && echo no || echo yes)"
printf 'PROFILE=x\n' > "$OV/bad-rig.env"; mkdir -p "$OVR/local/bad"; cp "$OV/bad-rig.env" "$OVR/local/bad/rig.env"
check "rig.env may not choose the profile or the overlay" "yes" \
"$(in_ov env OVERLAY=local/bad bash ports.sh active >/dev/null 2>&1 && echo no || echo yes)"
# Addons: the overlay's is found before rig's own, and runs from rig's ctrl/.
check "an overlay's addon comes before rig's of the same name" \
"overlay-metallb from $OVR/ctrl with $OVR/ctrl" \
"$(in_ov env OVERLAY=local/My_Env ADDONS=metallb bash addons.sh install 2>&1 | grep '^overlay-metallb')"
# ctrl/.env is this rig's: a pinned block would follow every overlay.
check "ports.sh persist refuses while an overlay is set" "yes" \
"$(in_ov env OVERLAY=local/My_Env bash ports.sh persist >/dev/null 2>&1 && echo no || echo yes)"
# make's $(shell) must see an OVERLAY given as a make argument (make < 4.4 does not pass it).
check "make -n tilt OVERLAY=... asks for the overlay's context" "kind-data" \
"$(cd .. && make --no-print-directory -n tilt OVERLAY=examples/data 2>/dev/null | grep -m1 'tilt ' | sed -n 's/.*--context \([^ ]*\).*/\1/p')"
# rig reads an overlay and never writes into it; its values never reach a committed kit.
sum_ov() { (cd "$OVR/local/My_Env" && find . -type f | sort | xargs sha256sum | sha256sum); }
before=$(sum_ov)
echo 'OVERLAY=local/My_Env' >> "$OVR/ctrl/.env"
in_ov bash ports.sh active >/dev/null 2>&1
in_ov bash addons.sh list >/dev/null 2>&1
in_ov bash -c 'source ./lib/config.sh; load_config >/dev/null; render_kind_config >/dev/null' 2>/dev/null
in_ov bash standalone.sh write >/dev/null 2>&1
in_ov bash standalone.sh export "$OV/export" >/dev/null 2>&1
check "rig writes nothing into an overlay" "$before" "$(sum_ov)"
check "an overlay's values never reach a committed kit" "0" \
"$(grep -rlE 'selftest-overlay-sentinel|local/My_Env' "$OVR/standalone" 2>/dev/null | wc -l)"
check "a committed kit holds no path of this machine" "0" \
"$(grep -rlF "$OVR" "$OVR/standalone" 2>/dev/null | wc -l)"
note "the Tiltfile hardcodes nothing"
# The Tiltfile asks ports.sh for its context; a literal kind-<name> would undo that.
check "no literal kind-<name>" "0" "$(grep -cE "['\"]kind-[a-z0-9]" Tiltfile)"
check "guards on the variable" "1" "$(grep -c 'allow_k8s_contexts(CTX)' Tiltfile)"
check "asks ports.sh for facts" "1" "$(grep -c "local('bash ports.sh active'" Tiltfile)"
check "hands over to the overlay's Tiltfile" "1" "$(grep -c "include(OVERLAY + '/Tiltfile')" Tiltfile)"
note "standalone kits are generated, current, and call only real verbs"
# A kit left stale by a change to rig fails here, not on another machine.
check "every kit matches what rig generates now" "yes" \
"$(bash standalone.sh check >/dev/null 2>&1 && echo yes || echo "no — run make standalone")"
# Every kit Makefile target must call a verb its script's own dispatch accepts.
verbs_of() {
sed -n '/^case "\$cmd" in/,/^esac/p' "$1" | grep -oE '^ [a-z]+\)' | tr -d ' )'
}
kits=0
for mk in ../standalone/*/Makefile; do
[ -f "$mk" ] || continue
kit=$(dirname "$mk"); kits=$((kits + 1))
for target in $(grep -oE '^[a-z][a-z-]*:' "$mk" | tr -d ':' | grep -vx help); do
line="$(make --no-print-directory -s -n -f "$mk" "$target" 2>/dev/null | head -1)"
script=$(basename "$(printf '%s' "$line" | awk '{print $2}')")
verb=$(printf '%s' "$line" | awk '{print $NF}')
check "$(basename "$kit"): make $target -> $script $verb, a verb it accepts" "yes" \
"$(verbs_of "$kit/$script" | grep -qx "$verb" && echo yes || echo "no: '$verb'")"
done
check "$(basename "$kit"): no \`mini\` target, which already means minimal footprint" "0" \
"$(grep -cE '^mini:' "$mk")"
done
check "there is a kit for every profile" "$(config_profiles | wc -l)" "$kits"
# An export carries this machine's choices but never its credentials; committed kits carry neither.
# Proven with sentinel values in a scratch copy, since the real ctrl/.env may leave them empty.
SX="$TMP/export-proof"; copy_rig "$SX/rig"
mkdir -p "$SX/selftest-sentinel-choice/overlays/dev" # a named MANIFESTS_DIR must exist
cat >> "$SX/rig/ctrl/.env" <<'EOF'
REGISTRY_USER=selftest-sentinel-user
REGISTRY_PASSWORD=selftest-sentinel-password
MANIFESTS_DIR=../selftest-sentinel-choice/overlays/dev
EOF
( cd "$SX/rig/ctrl" && bash standalone.sh export "$SX/out" >/dev/null 2>&1 )
count_in() { grep -rcF -- "$1" "$2" 2>/dev/null | awk -F: '{s+=$2} END{print s+0}'; }
check "export: carries this machine's choices" "yes" \
"$([ "$(count_in selftest-sentinel-choice "$SX/out")" -gt 0 ] && echo yes || echo no)"
check "export: carries no credential" "0" \
"$(( $(count_in selftest-sentinel-user "$SX/out") + $(count_in selftest-sentinel-password "$SX/out") ))"
check "per-profile kits: carry neither, whatever this machine has" "0" \
"$( (cd "$SX/rig/ctrl" && source ./lib/config.sh && for p in $(config_profiles); do config_snapshot "$p"; done) \
| grep -cE 'selftest-sentinel-(choice|user|password)')"
check "export: refuses to write inside the repository" "yes" \
"$( (bash standalone.sh export ../standalone/selftest-mine >/dev/null 2>&1) && echo no || echo yes)"
note "rig's addons apply verified files, never URLs"
# The offline profile must need no network for manifests: each addon asks deps.sh for a
# pinned manifest, verified on disk (versions.md). Their images still need preloading.
check "no rig addon applies a URL" "0" \
"$(cat addons/*.sh | grep -cE 'apply -f "?https?://')"
check "every manifest an addon asks for is pinned with a sum" "" \
"$(for n in $(grep -ohE 'deps\.sh manifest [A-Z_]+' addons/*.sh | awk '{print $3}' | sort -u); do
grep -q "^${n}_MANIFEST_URL=" versions.env && grep -q "^${n}_MANIFEST_SHA256=[0-9a-f]\{64\}$" versions.env \
|| printf '%s ' "$n"; done)"
note "withdrawn stays withdrawn (STALE.md)"
# One check per entry; the reasoning is in STALE.md, not here.
check "✖ S1 rig's Tiltfile has no Images section of its own" "0" "$(grep -c '^# ── Images' Tiltfile)"
check "✖ S2 local/ is where overlays live, and ignored" "yes" \
"$(grep -qx '/local/' ../.gitignore && echo yes || echo no)"
# Patterns assembled from fragments so this file does not match itself.
COPIES_PAT="$(printf '%s' 'ac' 'me-rig|ac' 'mebank')"
HOUSE_PAT="$(printf '%s' 'semes' 'ter|local' '\.ar\b')"
check "✖ S2 no example environment name from the copies era" "0" \
"$(cd .. && grep -rIlE "$COPIES_PAT" . --exclude-dir=def --exclude-dir=local --exclude=STALE.md 2>/dev/null | wc -l)"
check "✖ S3 ctrl/addons makes the cluster work, nothing more" "cert-manager metallb metrics-server" \
"$(ls addons/ | sed 's/\.sh$//' | sort | xargs)"
check "✖ S3 versions.env pins no workload image" "0" \
"$(grep -cE '^(POSTGRES|REDIS|AIRFLOW)_IMAGE=' versions.env)"
check "✖ S4 no namespace named after the cluster" "0" "$(grep -c "CLUSTER + ':namespace'" Tiltfile)"
check "✖ S5 rig's examples left ctrl/k8s" "no" "$([ -d k8s/overlays ] && echo yes || echo no)"
check "✖ S5 .env.example does not pin MANIFESTS_DIR" "0" "$(grep -c '^MANIFESTS_DIR=' .env.example)"
check "✖ S6 no client or data example profile" "0" \
"$(ls env.d/ | grep -cE '^(client|data)\.')"
check "✖ S7 no house path or host name in rig" "0" \
"$(cd .. && grep -rIlE "$HOUSE_PAT" . --exclude-dir=def --exclude-dir=local --exclude=STALE.md 2>/dev/null | wc -l)"
note "the installer detects what each kind of machine needs"
# Host fixtures (tests/hosts/): a stand-in root per machine, detect run against it. The
# expensive machines — the Workspace, a WSL install — are exactly the ones you cannot
# rebuild to test on, so their shapes are replayed here instead.
out="$(bash ./hosttest.sh 2>&1)"
check "every host fixture detects as expected" "$(ls -d ../tests/hosts/*/ | wc -l) host fixture(s) as expected, 0 not" \
"$(tail -1 <<< "$out")"
# A snapshot is carried off a machine that may be someone else's: it must hold only the
# files detect reads, and nothing that names the machine or the person.
SN="$TMP/snapshot/s"
bash ./deps.sh snapshot "$SN" >/dev/null 2>&1
check "snapshot writes only what detect reads" \
"expect.txt facts.txt root/etc/os-release root/proc/meminfo root/proc/sys/vm/overcommit_memory root/proc/version" \
"$( (cd "$SN" 2>/dev/null && find . -type f | sed 's|^\./||' | grep -vE '^root/(etc/wsl\.conf|mnt/c/Users/user/\.wslconfig)$' | sort | xargs) )"
check "snapshot names no host, user or home" "0" \
"$(grep -rlE "$(hostname)|${USER:-nobody}|/home/" "$SN" 2>/dev/null | wc -l)"
check "and replays as the machine it was taken on" "yes" \
"$(bash ./hosttest.sh "$SN" >/dev/null 2>&1 && echo yes || echo no)"
note "the dev loop parses — needs tilt and kubectl, not a cluster"
# A throwaway kubeconfig with kind-named entries (Tilt trusts kind contexts) and a
# kubectl that swallows `apply`: the Tiltfile evaluates for real, nothing is contacted.
# The second run is a copy under another name, the case that once failed at load.
if ! command -v tilt >/dev/null || ! command -v kubectl >/dev/null; then
printf ' skip tilt or kubectl is not installed\n'
else
FK="$TMP/fake-kube"; mkdir -p "$FK"
real_kubectl=$(command -v kubectl)
printf '#!/usr/bin/env bash\nfor a in "$@"; do [ "$a" = apply ] && { cat >/dev/null; exit 0; }; done\nexec %q "$@"\n' \
"$real_kubectl" > "$FK/kubectl"
chmod +x "$FK/kubectl"
parses() { # cluster-name [env...] -> the manifests Tilt would deploy, or the error
local name="$1"; shift
cat > "$FK/kubeconfig" <<EOF
apiVersion: v1
kind: Config
clusters: [{name: kind-$name, cluster: {server: "https://127.0.0.1:9"}}]
contexts: [{name: kind-$name, context: {cluster: kind-$name, user: kind-$name}}]
users: [{name: kind-$name, user: {token: selftest}}]
current-context: kind-$name
EOF
env "$@" KUBECONFIG="$FK/kubeconfig" PATH="$FK:$PATH" \
timeout 120 tilt alpha tiltfile-result --context "kind-$name" > "$FK/out.json" 2> "$FK/err" \
&& grep -o '"Name": *"[^"]*"' "$FK/out.json" | sed 's/.*"\([^"]*\)"$/\1/' | sort -u | xargs \
|| grep -m1 -iE 'error|no object' "$FK/err"
}
check "the starter overlay parses" "example-service infra uncategorized" "$(parses rig)"
check "and under another name" "example-service infra uncategorized" \
"$(parses selftest-copy CLUSTER=selftest-copy)"
check "the data overlay parses" "items-api uncategorized" "$(parses data OVERLAY=examples/data)"
fi
note "the examples are overlays that work as shipped"
# They are what a real overlay is copied from, so they must at least parse.
bad=""
for f in ../examples/*/addons/*.sh; do [ -e "$f" ] && { bash -n "$f" 2>/dev/null || bad+="$f "; }; done
check "every example addon parses" "" "$bad"
if command -v python3 >/dev/null; then
bad=""
for f in ../examples/*/dags/*.py; do
[ -e "$f" ] && { python3 -c 'import ast, sys; ast.parse(open(sys.argv[1]).read())' "$f" 2>/dev/null || bad+="$f "; }
done
check "every example DAG parses" "" "$bad"
else
printf ' skip python3 is not installed\n'
fi
printf '\n'
if [ "$rc" -eq 0 ]; then
printf '%d checks passed — rig still does what it says\n' "$passed"
else
printf 'FAILED — a decision above has drifted; read the comment next to it\n' >&2
fi
exit "$rc"