self tests

This commit is contained in:
2026-09-13 21:57:26 -03:00
parent 49a9f8ee57
commit e0426ecb01
9 changed files with 512 additions and 25 deletions

View File

@@ -0,0 +1,50 @@
# EXAMPLE — a component image. Copy, rename, replace.
#
# Named like the manifest it feeds and the resource it becomes:
#
# ctrl/Dockerfile.api -> image <cluster>-api -> image: in k8s/base/api.yaml
#
# That image string is the ONLY thing connecting the three. Nothing checks it;
# a typo shows up as a pod stuck in ImagePullBackOff pulling from the public
# index, which reads like a network problem and is not one.
#
# ── the one that catches everyone ──────────────────────────────────────────
# The Tiltfile passes two paths with DIFFERENT bases, in adjacent arguments:
#
# context='..' the REPO ROOT (the Tiltfile is in ctrl/)
# dockerfile='Dockerfile.api' relative to the TILTFILE, so ctrl/Dockerfile.api
#
# So every COPY below is resolved against the repo root, NOT against this file's
# directory. A file sitting right beside this one is still reached as `ctrl/`:
#
# COPY ctrl/nginx.conf /etc/nginx/conf.d/default.conf # correct
# COPY nginx.conf /etc/nginx/conf.d/default.conf # fails — no such file
#
# Nothing warns you. The build just cannot find a file that is visibly there.
FROM python:3.12-slim
WORKDIR /app
# Dependencies first, in their own layer: they change far less often than the
# code, so a source edit does not reinstall them on every rebuild.
COPY api/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Repo-root relative — see above.
COPY api/ ./api/
# Match this with the containerPort in the manifest and the target of the
# Service in front of it.
EXPOSE 8000
CMD ["python", "-m", "api"]
# ── live_update ────────────────────────────────────────────────────────────
# The sync in the Tiltfile's docker_build must land where this image expects it:
#
# live_update=[sync('../api', '/app/api')]
#
# matches `COPY api/ ./api/` with WORKDIR /app. If the two disagree, Tilt syncs
# into a path nothing reads and the container keeps serving the built copy —
# edits appear to do nothing, with no error anywhere.

139
rig/ctrl/Tiltfile Normal file
View File

@@ -0,0 +1,139 @@
# The dev loop. `make tilt` from the project root, or `cd ctrl && tilt up`.
#
# This file ships with rig and works unedited: rig's own k8s/base already boots,
# so `make tilt` comes up with a running cluster and no editing at all. What it
# deploys is two EXAMPLES — replace them, and add your own images and resources
# in the two marked sections near the bottom. The catalogue after them has the
# blocks to paste, with the parts that are easy to get wrong already commented.
#
# rig supplies this file; it does not own it. Nothing in rig reads it back, and
# nothing here is regenerated — edit it freely, the way you would edit
# k8s/base/example-mock.yaml. rig owns the machine, you own the workload.
#
# Nothing below is hardcoded to this directory, deliberately. Every other
# project here writes its slug into the Tiltfile five or six times by hand, so a
# copy of the project deploys into the original's cluster until someone
# remembers to edit all of them. A rig is meant to be copied and renamed, so it
# asks instead.
# ── who we are, and on which ports ─────────────────────────────────────────
# One question to rig, answered by ctrl/ports.sh, which resolves it through
# lib/config.sh — the same path every other rig script takes. That is the point:
# the cluster name is NOT the bare directory name (it is lowercased and reduced
# to a DNS label), and the ports honour anything pinned in ctrl/.env. Recomputing
# either of those here in Starlark is how two copies end up disagreeing about
# which cluster they are talking to.
_facts = str(local('bash ports.sh active', quiet=True)).split()
CLUSTER = _facts[0]
CTX = _facts[1]
HTTP = _facts[2]
HTTPS = _facts[3]
TILT = _facts[4]
REGISTRY = _facts[5]
# Where the manifests live. rig's own are the default; point MANIFESTS_DIR in
# ctrl/.env at an overlay versioned somewhere else and rig stops owning them —
# see k8s/README.md. Real manifests usually change on a different cadence, by
# different people, under different review.
#
# The value is REPO-ROOT relative, because that is the root everything else in
# rig is expressed against. This file runs in ctrl/, so prefix rather than
# assume: '../' + 'ctrl/k8s/overlays/dev' and '../' + '../platform/overlays/dev'
# are both right, where stripping a leading 'ctrl/' would only fix the first.
MANIFESTS = '../' + _facts[6]
# ── refuse to deploy into the wrong cluster ────────────────────────────────
# Tilt snapshots the kubectl context at startup, BEFORE parsing this file, so it
# cannot be switched from here — only refused. `make tilt` passes --context for
# you; this catches a bare `tilt up` after some other project moved the global
# context.
allow_k8s_contexts(CTX)
if k8s_context() != CTX:
fail("Wrong kubectl context: '%s'. This is %s — run: make tilt, or tilt up --context %s"
% (k8s_context(), CLUSTER, CTX))
# The namespace has to exist before anything lands in it, and kustomize does not
# guarantee ordering across resources. Creating it here is idempotent.
local('kubectl --context %s create namespace %s --dry-run=client -o yaml | kubectl --context %s apply -f -'
% (CTX, CLUSTER, CTX), quiet=True)
# ── images go to this environment's own registry ───────────────────────────
# Fail closed. Tilt can usually infer the kind registry on its own, but "usually"
# is an inference, and when it misses, an unqualified name like 'app' quietly
# means docker.io/library/app — a push to the public index instead of the
# registry two lines away. rig runs that registry; name it.
default_registry('localhost:' + REGISTRY)
k8s_yaml(kustomize(MANIFESTS))
# ── Images ─────────────────────────────────────────────────────────────────
# (nothing yet — rig's examples run upstream images. Add docker_build calls here.)
# ── Resources ──────────────────────────────────────────────────────────────
# (nothing yet — add k8s_resource calls here to name and order what you deploy.)
# Everything with no dev loop of its own, gathered so it does not clutter the UI.
k8s_resource(
objects=[CLUSTER + ':namespace'],
new_name='infra',
)
# ═══════════════════════════════════════════════════════════════════════════
# Catalogue — paste what you need, delete the rest.
#
# These are the shapes that recur across every project here, with the reasoning
# kept next to them. They are comments so this file runs as-is.
# ═══════════════════════════════════════════════════════════════════════════
#
# ── build an image ─────────────────────────────────────────────────────────
# The one genuinely non-obvious thing in the whole corpus: `context` and
# `dockerfile` are relative to DIFFERENT directories, in adjacent arguments,
# and nothing warns you.
#
# context= the REPO ROOT — this file is in ctrl/, so '..'
# dockerfile= relative to THIS file — so 'Dockerfile.api' is ctrl/Dockerfile.api
#
# Every COPY inside those Dockerfiles is therefore repo-root relative: a file
# sitting BESIDE the Dockerfile is still reached as `COPY ctrl/nginx.conf`.
#
# docker_build(
# CLUSTER + '-api', # must match `image:` in the manifest —
# context='..', # that string is the only thing
# dockerfile='Dockerfile.api', # connecting the two
# ignore=['.git', 'def', '.venv', 'node_modules', '__pycache__'],
# live_update=[sync('../api', '/app/api')],
# )
#
# ── name and order a resource ──────────────────────────────────────────────
# k8s_resource('api', resource_deps=['postgres'], labels=['app'])
# k8s_resource('gateway', resource_deps=['api', 'ui'], labels=['app'])
#
# ── reload the gateway when its config changes ─────────────────────────────
# A Caddyfile arriving via configMapGenerator with disableNameSuffixHash does
# NOT roll the pod — the ConfigMap name never changes, so nothing tells the
# Deployment anything happened. Without this you edit the routes and watch
# nothing take effect.
#
# local_resource(
# 'gateway-reload',
# cmd='kubectl --context %s -n %s rollout restart deployment/gateway' % (CTX, CLUSTER),
# deps=['k8s/base/Caddyfile'],
# resource_deps=['gateway'],
# auto_init=False,
# )
#
# ── an overlay whose secretGenerator reads outside its own directory ───────
# kustomize refuses to read above the kustomization root unless told to. Only
# add this if you actually have such a generator; it loosens a safety check.
#
# k8s_yaml(kustomize(MANIFESTS, flags=['--load-restrictor=LoadRestrictionsNone']))
#
# ── reach a service directly, bypassing the gateway ────────────────────────
# For a DB client or an admin UI. Prefer routing through the gateway: host ports
# are a single shared namespace across every project on this machine, which is
# why rig derives a block per environment in the first place. If you do need
# one, take it from this environment's own block rather than picking a number.
#
# k8s_resource('postgres', port_forwards=[str(int(HTTP) + 5) + ':5432'])

View File

@@ -21,9 +21,14 @@
# NODES and AUDIT are deliberately NOT here: they are properties of the chosen
# ctrl/k8s/kind-config*.yaml.tpl and are read back out of it below, so there is
# one place that decides the shape of the cluster rather than two that can drift.
#
# REGISTRY_PORT and MANIFESTS_DIR were missing here while ctrl/.env set them, so
# the caller's env silently LOST to the file for those two — the one precedence
# rule this header states. Both are now listed; the other twelve are unchanged.
CONFIG_OVERRIDABLE="PROFILE CLUSTER K8S_VERSION KIND_CONFIG ADDONS
REGISTRY_MODE INGRESS_MODE DNS_MODE TILT_PORT
SOURCE ARCH DEPS_SOURCE HTTP_PORT HTTPS_PORT"
SOURCE ARCH DEPS_SOURCE HTTP_PORT HTTPS_PORT
REGISTRY_PORT MANIFESTS_DIR"
# The containing folder's name, reduced to something kind accepts as a cluster
# name (a DNS label: lowercase alphanumerics and dashes). Run from ctrl/, so the
@@ -93,6 +98,12 @@ load_config() {
TILT_PORT="${TILT_PORT:-$((base + 2))}"
REGISTRY_PORT="${REGISTRY_PORT:-$((base + 3))}"
# Where the workload's manifests live, repo-root relative. Defaulted here so
# it is always resolved rather than sometimes-set: it is the seam that lets
# the real manifests be versioned away from the installer, and a consumer
# should not have to know whether anyone filled it in. See k8s/README.md.
MANIFESTS_DIR="${MANIFESTS_DIR:-ctrl/k8s/overlays/dev}"
# Profiles name a k8s minor (v1_36); versions.env holds the pinned digest.
local var="NODE_IMAGE_${K8S_VERSION}"
NODE_IMAGE="${!var:-}"

View File

@@ -19,7 +19,7 @@
# written into ctrl/.env, so it becomes pinned, visible and editable rather than
# a number that appears from nowhere. Anything already in ctrl/.env wins.
#
# Usage: ports.sh show | derive | persist
# Usage: ports.sh show | active | derive | persist
set -euo pipefail
cd "$(dirname "$0")"
@@ -38,6 +38,33 @@ derive() {
DERIVED_REGISTRY=$((base + 3))
}
# The resolved facts a consumer outside bash needs, machine-readable:
#
# CLUSTER KUBECONTEXT HTTP HTTPS TILT REGISTRY MANIFESTS_DIR
#
# Identity and ports together, because they are one fact set — the header above
# says so: both derive from the directory name so that copies never collide. A
# consumer needs all of them or none, and fetching them separately is how two
# end up disagreeing. MANIFESTS_DIR rides along because the one consumer that
# needs the addressing is the one that needs to know what to deploy.
#
# Space-separated, so MANIFESTS_DIR must not contain spaces. Everything else in
# rig already assumes that of paths — kind, docker and kubectl all do.
#
# `derive` answers a DIFFERENT question — what the directory name alone implies
# — and deliberately ignores ctrl/.env. Configuring anything from it would
# silently contradict this file's own rule that "anything already in ctrl/.env
# wins". `active` is what anything downstream should read.
#
# Why this exists at all: the cluster name is not the bare directory name.
# default_cluster_name() lowercases it and replaces every character outside
# [a-z0-9-], because it has to be a DNS label. Re-deriving that in another
# language is how a copy in `My_Project/` ends up guarding the wrong context.
active() {
load_config
echo "$CLUSTER $KUBECONTEXT $HTTP_PORT $HTTPS_PORT $TILT_PORT $REGISTRY_PORT $MANIFESTS_DIR"
}
show() {
derive
echo "environment $CLUSTER"
@@ -98,6 +125,7 @@ persist() {
case "${1:-show}" in
show) show ;;
derive) derive; echo "$DERIVED_HTTP $DERIVED_HTTPS $DERIVED_TILT $DERIVED_REGISTRY" ;;
active) active ;;
persist) persist ;;
*) echo "usage: $0 [show|derive|persist]" >&2; exit 1 ;;
*) echo "usage: $0 [show|active|derive|persist]" >&2; exit 1 ;;
esac

193
rig/ctrl/selftest.sh Executable file
View File

@@ -0,0 +1,193 @@
#!/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 pins` 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 "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
PROFILE) echo "client" ;; # env.d/client.env exists
K8S_VERSION) echo "v1_35" ;; # NODE_IMAGE_v1_35 is pinned
KIND_CONFIG) echo "kind-config.client.yaml.tpl" ;; # the shape must exist
*_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 "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"