275 lines
14 KiB
Bash
Executable File
275 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# What rig has settled, written down as assertions.
|
|
#
|
|
# These are documentation that runs. Each check is ONE decision that has already
|
|
# been made, with the reason above it — not coverage, and deliberately not an
|
|
# exhaustive sweep of use cases. rig's own index says a rule without its reason
|
|
# gets overridden the first time it is inconvenient; a rule nobody can restate
|
|
# is worse. So the test says what was decided, and failing it should read as
|
|
# "you are about to undo this" rather than "something broke".
|
|
#
|
|
# Scope, on purpose:
|
|
# - no cluster, no docker, no network. It must be cheap enough to actually run.
|
|
# - it asserts about RIG. `make check` asserts about the MACHINE and never
|
|
# fails; this exits 1, the way `make standalone check` does.
|
|
# - what actually deploys is not testable here. `tilt ci` stays a manual step.
|
|
#
|
|
# Usage: make selftest (or: bash ctrl/selftest.sh)
|
|
set -uo pipefail # NOT -e: one failing check must not abort the rest
|
|
cd "$(dirname "$0")"
|
|
|
|
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"; }
|
|
|
|
# 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"
|
|
# rig assumes no configuration. A profile is an overlay on built-in defaults, so
|
|
# a rig with no env.d/ at all must resolve, report, and still generate a kit —
|
|
# and naming a profile that does not exist must still be an error, because a
|
|
# typo that silently fell back to the defaults would be worse than a failure.
|
|
NP="$(mktemp -d)"
|
|
cp -r .. "$NP/rig"; rm -rf "$NP/rig/ctrl/env.d"; sed -i '/^PROFILE=/d' "$NP/rig/ctrl/.env" 2>/dev/null
|
|
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" "7" \
|
|
"$(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 two other files — the Makefile takes
|
|
# $(word 2) and $(word 5), the Tiltfile takes _facts[0]..[6]. Insert a field in
|
|
# the middle and nothing errors: Tilt simply guards on the wrong context or
|
|
# binds the wrong port. The field count and order are the contract, so they are
|
|
# pinned here rather than left to whoever edits ports.sh next.
|
|
FACTS="$(bash ports.sh active)"
|
|
check "active: exactly 7 fields" "7" "$(printf '%s' "$FACTS" | wc -w)"
|
|
read -r F_CLUSTER F_CTX F_HTTP F_HTTPS F_TILT F_REG F_MANIFESTS <<< "$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)"
|
|
check "active: field 7 is a path" "yes" \
|
|
"$([ -n "$F_MANIFESTS" ] && [ "${F_MANIFESTS#-}" = "$F_MANIFESTS" ] && echo yes || echo no)"
|
|
# 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"
|
|
# lib/config.sh states one precedence rule: versions.env < env.d/<profile> <
|
|
# ctrl/.env < the caller's env. It is enforced by CONFIG_OVERRIDABLE, a
|
|
# hand-maintained list — and a key missing from it loses to the file SILENTLY.
|
|
# REGISTRY_PORT and MANIFESTS_DIR were both missing on 2026-09-13 and were found
|
|
# by accident.
|
|
#
|
|
# So this loop is generated FROM the list: add a key to CONFIG_OVERRIDABLE and
|
|
# this test starts asking about it without anyone remembering to come here.
|
|
# Three keys name something that must exist and are validated at load, so they
|
|
# get a real alternative rather than a sentinel.
|
|
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" ;;
|
|
MANIFESTS_DIR) echo "../elsewhere/overlays/dev" ;;
|
|
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 used to compute the cluster name itself and sed TILT_PORT out of
|
|
# ctrl/.env — a second derivation of values lib/config.sh already owns, which
|
|
# could disagree with it after `make ports persist`. It now reads ports.sh
|
|
# active. Nothing structurally prevents the sed coming back, so the agreement is
|
|
# asserted against the real `make -n` output rather than against the source.
|
|
# --no-print-directory and a grep, not `tail -1`: run from `make selftest` this
|
|
# is a RECURSIVE make, and the "Entering/Leaving directory" lines go to STDOUT.
|
|
# tail -1 then reads "make[1]: Leaving directory ..." and both checks below fail
|
|
# — but only when invoked through make, never when the script is run directly.
|
|
# A test that passes one way and fails the other is worse than no test.
|
|
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 NOT the bare directory name: kind needs a DNS label, so
|
|
# default_cluster_name lowercases it and replaces everything outside [a-z0-9-].
|
|
# Re-deriving that anywhere else is how a copy ends up guarding the wrong
|
|
# context — which is exactly why the Tiltfile asks instead of computing.
|
|
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.
|
|
sed -i '/^CLUSTER=/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"
|
|
# Not a change-detector. The block is derived, never stored, so if the
|
|
# derivation shifts then every EXISTING environment's ports move underneath it —
|
|
# a running cluster keeps its old ports while rig starts reporting new ones, and
|
|
# `make ports` stops describing reality. Anchored to three known names.
|
|
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 sits inside a host project's tree but must be copyable straight out of it:
|
|
# no imports, no paths, no assumption the host is there. This grep is the whole
|
|
# test of that claim, and until now it lived only in prose and in whoever
|
|
# remembered to run it.
|
|
#
|
|
# The pattern is assembled from fragments so this file does not match ITSELF.
|
|
# Writing it literally would fail forever; excluding this file instead would put
|
|
# a blind spot in the one check that guards the boundary.
|
|
HOST_PAT="$(printf '%s' 'sole' 'print' '|\b' 'sp' 'r\b')"
|
|
check "no host-project references" "0" \
|
|
"$(cd .. && grep -rIl -iE "$HOST_PAT" . --exclude-dir=def 2>/dev/null | wc -l)"
|
|
|
|
|
|
note "the Tiltfile hardcodes nothing"
|
|
# Every other Tiltfile on this machine writes its slug in five or six times by
|
|
# hand, so a copied project deploys into the original's cluster until someone
|
|
# edits all of them. rig's asks ports.sh. A literal kind-<name> here would mean
|
|
# that has been undone.
|
|
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)"
|
|
|
|
|
|
note "standalone kits are generated, current, and call only real verbs"
|
|
# The kits under standalone/<profile>/ are rig flattened into single files, one
|
|
# per profile. A kit left behind by a change to rig is exactly the drift they
|
|
# replaced — rigmini.sh once said 2 GB per node long after rig measured 800 MB —
|
|
# so a stale kit fails here rather than waiting to be noticed 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")"
|
|
|
|
# Each kit's Makefile exists so nothing wrapping these scripts has to GUESS how to
|
|
# call them. A generated Makefile once did guess: `rigmini.sh on`, not a verb,
|
|
# and a bare `rigdeps.sh` for "check and report", which installs. So every
|
|
# target's default verb must be one its script's own dispatch accepts — read
|
|
# from that dispatch, not from a list here that could drift from it.
|
|
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 is "take the setup I have here somewhere else", so it carries this
|
|
# machine's CHOICES — profile, ports, manifest dir — and never its credentials:
|
|
# ctrl/.env can hold registry and mirror logins next to those choices. The
|
|
# committed per-profile kits carry neither, since they must be the same on any
|
|
# machine. Proven with sentinel values in a scratch copy, because the real
|
|
# ctrl/.env may have those keys empty — and an empty value proves nothing.
|
|
SX="$TMP/export-proof"; mkdir -p "$SX"; cp -r .. "$SX/rig"
|
|
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 "optional — needs tilt and this rig's cluster"
|
|
# Parsing the Tiltfile for real is the only way to know it still evaluates, but
|
|
# Tilt snapshots a kubectl context before parsing, so it cannot run without a
|
|
# cluster. Skipped rather than failed when there is none, the same way docgen
|
|
# skips its graphgen section.
|
|
if ! command -v tilt >/dev/null; then
|
|
printf ' skip tilt is not installed\n'
|
|
elif ! kubectl config get-contexts -o name 2>/dev/null | grep -qx "$F_CTX"; then
|
|
printf " skip no %s context — run 'make cluster up' to include this\n" "$F_CTX"
|
|
else
|
|
out="$(tilt alpha tiltfile-result --context "$F_CTX" 2>&1)"
|
|
check "Tiltfile evaluates" "yes" \
|
|
"$(printf '%s' "$out" | grep -q '"Manifests"' && echo yes || echo "no: $(printf '%s' "$out" | tail -1)")"
|
|
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"
|