diff --git a/rig/.gitignore b/rig/.gitignore index a1666f6..bcc5255 100644 --- a/rig/.gitignore +++ b/rig/.gitignore @@ -9,7 +9,9 @@ ctrl/.env # generated: the .dot is a build artifact rendered from arch/*.json, never hand-edited. # The .svg IS committed — onboarding material should render in a repo browser. arch/*.dot -ctrl/Tiltfile.gen +# ctrl/Tiltfile.gen was here for a generator that no longer exists. ctrl/Tiltfile +# is now a real, committed file that derives its values when Tilt parses it, so +# there is nothing generated to ignore. # binaries pulled by `make deps-bundle` for the air-gapped installer image vendor diff --git a/rig/BOOTSTRAP.md b/rig/BOOTSTRAP.md index e217675..9154a47 100644 --- a/rig/BOOTSTRAP.md +++ b/rig/BOOTSTRAP.md @@ -246,9 +246,19 @@ Reachability is a plain kind port mapping: no ingress controller and no MetalLB. Caddy maps `.local.ar` onto the host port (`~/wdir/semester/ppl/local/Caddyfile`), with `*.local.ar` resolving to 127.0.0.1 through dnsmasq. That is the whole chain. -**The one file the scaffold still does not ship is `ctrl/Tiltfile`** — `make -tilt-up` runs `cd ctrl && tilt up`, and there is nothing to run until you write -one. Copy it from a live project; `unt` and `nvi` are closest to the plain shape. +**For `ctrl/Tiltfile`, copy rig's** rather than a live project's. rig ships one +that derives its cluster, context, ports and manifest directory from +`ctrl/ports.sh active` instead of hardcoding a slug, and carries a catalogue of +the blocks every project here ends up needing. Copying from `unt` or `nvi` is +what the estate did until now, and it is why the same Tiltfile preamble exists +in six places with the slug typed in by hand five times each. + +> **Two things in this document disagree with rig and are not settled.** It +> mandates Tilt ports in `10300–10399`, while rig derives a block from the +> directory name at `20000+` so copies cannot collide — a rig-managed project +> takes rig's. And it names `ctrl/k8s/.env.example`, which is the `broad` +> scaffold's layout; rig's is `ctrl/.env.example`. Both are this document +> describing the house scaffold from inside rig's tree. ## Run it diff --git a/rig/Makefile b/rig/Makefile index f668b53..d276a96 100644 --- a/rig/Makefile +++ b/rig/Makefile @@ -16,10 +16,26 @@ # Identity follows the FOLDER NAME, so this directory can be copied elsewhere, # renamed, and run as a separate environment with no edits. ctrl/.env overrides # it when you want a name that differs from the directory. +# +# Asked once, of ctrl/ports.sh, which resolves it through lib/config.sh: +# +# CLUSTER KUBECONTEXT HTTP HTTPS TILT REGISTRY MANIFESTS_DIR +# +# Read positionally, so the order is a contract — ctrl/selftest.sh pins it. +# +# This used to be sed over ctrl/.env plus a slug computed here, which is a +# SECOND derivation of values lib/config.sh already owns — and the two could +# disagree about the port after `make ports persist`, or about the name for any +# directory whose sanitised form differs from its raw one. One source now; the +# Tiltfile reads the same line. +FACTS := $(shell bash ctrl/ports.sh active 2>/dev/null) SLUG := $(shell echo '$(notdir $(CURDIR))' | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-' | sed 's/^-*//; s/-*$$//') -CLUSTER := $(or $(shell sed -n 's/^CLUSTER=//p' ctrl/.env 2>/dev/null),$(SLUG)) -KCTX := --context kind-$(CLUSTER) -TILT_PORT := $(shell sed -n 's/^TILT_PORT=//p' ctrl/.env 2>/dev/null) +# The fallback matters: ports.sh sources config.sh, and if a profile or .env is +# broken it exits non-zero. Losing the cluster name would send --context to the +# wrong place, so fall back to the folder rather than to empty. +CLUSTER := $(or $(word 1,$(FACTS)),$(SLUG)) +KCTX := --context $(or $(word 2,$(FACTS)),kind-$(SLUG)) +TILT_PORT := $(word 5,$(FACTS)) DEPSIMG := $(SLUG)-deps # Words after the target become the script's subcommand. Make would otherwise @@ -35,7 +51,7 @@ $(eval $(ARGS):;@:) .PHONY: $(ARGS) endif -.PHONY: help setup check mem deps deps-image pins cluster registry addons ports \ +.PHONY: help setup check selftest mem deps deps-image pins cluster registry addons ports \ newbox dockerhost docs tilt \ kind-up kind-down kind-reset tilt-up tilt-down @@ -50,6 +66,12 @@ setup: ## prepare this machine [core] [--share-docker] check: ## is this machine ready? reports, never fixes bash ctrl/check.sh +# The counterpart to check: that one asks about the MACHINE and never fails, +# this one asks about RIG and exits 1, the way pins does. The checks are written +# as the decisions they defend, so a failure names what is being undone. +selftest: ## does rig still do what it says? exits 1 if not + bash ctrl/selftest.sh + mem: ## memory, and any cap holding it [status|backup|restore] bash ctrl/mem.sh $(or $(ARGS),status) @@ -91,11 +113,16 @@ dockerhost: ## share Docker between distros [status|share|un docs: ## documentation [serve|graphs] (default serve) bash ctrl/docs.sh $(or $(ARGS),serve) -# --port is only passed when TILT_PORT is actually set. It comes from ctrl/.env, -# which does NOT carry it by default — ports are derived at runtime in -# lib/config.sh unless `make ports persist` has written them. Without the guard -# tilt receives a bare `--port` with no value and fails on the flag rather than -# on anything real. `make ports show` prints the derived block. +# --port is only passed when TILT_PORT resolved. It normally does, since FACTS +# above asks ports.sh — but ports.sh can fail on a broken profile, and without +# the guard tilt receives a bare `--port` with no value and fails on the flag +# rather than on anything real. Tilt's own default is 10350, which is the number +# every project on this machine is trying not to collide on, so falling back to +# it silently is worse than not passing the flag. +# +# The Tiltfile asks ports.sh for the rest itself — cluster, registry and where +# the manifests are — so nothing needs passing here beyond what tilt's own flags +# require. tilt: ## dev loop [up|down] (default up) cd ctrl && tilt $(or $(ARGS),up) $(KCTX) $(if $(filter down,$(ARGS)),,$(if $(TILT_PORT),--port $(TILT_PORT))) @@ -110,6 +137,11 @@ tilt: ## dev loop [up|down] (default # # `cluster list` and `cluster free` have no hyphenated twin on purpose — they # are rig's own, with nothing to be consistent with. +# +# Nothing outside this file reads these names: the script is `ctrl/cluster.sh` +# and it takes the verb. So rename them, delete the ones you never type, or add +# the spelling your own projects use — an alias is two lines, and adding one +# costs nothing but a line in .PHONY above. kind-up: ## alias for `cluster up` bash ctrl/cluster.sh up @@ -120,10 +152,10 @@ kind-down: ## alias for `cluster down` kind-reset: ## alias for `cluster reset` bash ctrl/cluster.sh reset -# These two match the other projects' spelling, but rig has no Tiltfile — there -# is nothing to run yet, and they fail the same way `make tilt` does. -tilt-up: ## alias for `tilt up` (rig has no Tiltfile yet) +# These two match the other projects' spelling. rig ships ctrl/Tiltfile, so they +# run — it deploys the examples in k8s/base until you replace them. +tilt-up: ## alias for `tilt up` cd ctrl && tilt up $(KCTX) $(if $(TILT_PORT),--port $(TILT_PORT)) -tilt-down: ## alias for `tilt down` (rig has no Tiltfile yet) +tilt-down: ## alias for `tilt down` cd ctrl && tilt down $(KCTX) diff --git a/rig/README.md b/rig/README.md index 3427ea9..3da367e 100644 --- a/rig/README.md +++ b/rig/README.md @@ -87,11 +87,33 @@ make cluster free # stop the others if memory is tight make cluster down # remove this cluster and its registry ``` -**`make tilt` has nothing to run yet.** The target and its `tilt-up` / `tilt-down` -aliases exist so rig answers to the same spelling as every other project here, -but rig ships no `Tiltfile` — it builds the estate, it is not itself a service -with a dev loop. Add a `ctrl/Tiltfile` and the target works; until then it fails -on the missing file, not on anything rig did. +**The verbs are yours to change.** `cluster` is the script — `ctrl/cluster.sh` — +and every spelling above is a `Makefile` target that calls it. `make kind-up` is +an alias for `make cluster up`, kept because the other projects on this machine +answer to that spelling and muscle memory spans repos rather than stopping at +one. Nothing outside the `Makefile` reads these names, so rename them, drop the +ones you never type, or add whatever your own projects already say: each alias +is two lines at the bottom of the file, calling the same script the canonical +target does. + +**`make tilt` works on a fresh copy, unedited.** rig ships `ctrl/Tiltfile`, and +`k8s/base` already boots, so the dev loop comes up with the two examples running +and nothing to configure first. + +It hardcodes nothing. It asks `ctrl/ports.sh active` for this environment's +cluster name, kube context, ports and manifest directory — the same values every +other rig script resolves through `ctrl/lib/config.sh` — so a copied and renamed +rig deploys into its own cluster with no edits. Every other project here writes +its slug into the Tiltfile five or six times by hand, which is exactly the +collision `kind-config.yaml.tpl` exists to avoid. + +What it deploys is `MANIFESTS_DIR`, defaulting to rig's own `ctrl/k8s/overlays/dev`. +Point that at an overlay versioned elsewhere and rig stops owning the manifests. + +Replace the examples, then add your images and resources in the two marked +sections. The catalogue below them holds the blocks that recur across every +project here — `docker_build`, resource ordering, gateway reload, port-forwards — +with the parts that are easy to get wrong already commented. `make help` lists every target. diff --git a/rig/ctrl/Dockerfile.example b/rig/ctrl/Dockerfile.example new file mode 100644 index 0000000..f3d775a --- /dev/null +++ b/rig/ctrl/Dockerfile.example @@ -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 -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. diff --git a/rig/ctrl/Tiltfile b/rig/ctrl/Tiltfile new file mode 100644 index 0000000..25e6ef2 --- /dev/null +++ b/rig/ctrl/Tiltfile @@ -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']) diff --git a/rig/ctrl/lib/config.sh b/rig/ctrl/lib/config.sh index 86bec8a..de4f9af 100644 --- a/rig/ctrl/lib/config.sh +++ b/rig/ctrl/lib/config.sh @@ -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:-}" diff --git a/rig/ctrl/ports.sh b/rig/ctrl/ports.sh index 6dcf5db..4e34539 100755 --- a/rig/ctrl/ports.sh +++ b/rig/ctrl/ports.sh @@ -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 diff --git a/rig/ctrl/selftest.sh b/rig/ctrl/selftest.sh new file mode 100755 index 0000000..f954d32 --- /dev/null +++ b/rig/ctrl/selftest.sh @@ -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-" "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/ < +# 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- here would mean +# that has been undone. +check "no literal kind-" "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"