# Shared config loading. Sourced, never executed. # # The ecosystem convention is that scripts are standalone with no shared log # library — that still holds. This file is not a logging lib; it is the single # definition of how the config layers compose, which every script has to agree # on exactly. Precedence, weakest first: # # built-in defaults below; fill only what nothing else set # ctrl/versions.env pinned toolchain + image digests (committed) # ctrl/env.d/ cluster shape — OPTIONAL, examples ship as *.env.example # ctrl/.env machine-local values and secrets (gitignored) # the caller's env `make cluster up PROFILE=` (always wins) # # That last rule is why this is more than a few `source` lines: .env sets # PROFILE, so without snapshotting it would silently override the PROFILE the # user just typed on the command line. # # Run from ctrl/. # Values a user can reasonably override per-invocation. Anything set in the # environment when load_config runs is restored after the files are read. # 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 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 # repo root is the parent. default_cluster_name() { local n n=$(basename "$(cd .. && pwd)") n=$(echo "$n" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-') n=$(echo "$n" | sed 's/^-*//; s/-*$//') echo "${n:-rig}" } # Base of this environment's 10-port block. cksum is used rather than $RANDOM or # bash hashing because it is POSIX and returns the same value on every machine, # which is what makes the block reproducible instead of merely unique. derive_port_base() { local h; h=$(printf '%s' "$1" | cksum | awk '{print $1}') echo $((20000 + (h % 200) * 10)) } load_config() { local k saved="" for k in $CONFIG_OVERRIDABLE; do # ${!k+x} distinguishes "set but empty" from "unset" — an explicit # FOO= on the command line is a real choice and must survive. if [ -n "${!k+x}" ]; then saved+="$k=$(printf '%q' "${!k}")"$'\n' fi done set -a source ./versions.env # RIG_PORTABLE skips the machine-local layer. config_snapshot sets it, so a # generated standalone kit never carries this machine's .env — which holds # local values and, by its own description, secrets. if [ -z "${RIG_PORTABLE:-}" ] && [ -f ./.env ]; then source ./.env; fi set +a # Re-apply overrides now so PROFILE is the caller's before we pick the file. _config_restore "$saved" # A profile is an optional overlay, never a prerequisite. rig assumes no # configuration: with no profile named — or no env.d/ at all — it runs on the # built-in defaults below. What IS an error is naming a profile that does not # exist, because a typo must not quietly fall back to something else. local profile="${PROFILE:-}" if [ -n "$profile" ] && [ "$profile" != default ]; then if [ ! -f "./env.d/${profile}.env" ]; then echo "no such profile: env.d/${profile}.env" >&2 echo "available: $(config_profiles | tr '\n' ' ')" >&2 exit 1 fi set -a source "./env.d/${profile}.env" if [ -z "${RIG_PORTABLE:-}" ] && [ -f ./.env ]; then source ./.env; fi set +a _config_restore "$saved" fi # The defaults a profile would otherwise have to supply. Weakest of all: a # profile, ctrl/.env and the caller each override them. PROFILE_NAME="${PROFILE_NAME:-default}" ADDONS="${ADDONS-}" # local, not none: with no registry an unqualified image name means # docker.io/library/, and a default must not make that disclosure. REGISTRY_MODE="${REGISTRY_MODE:-local}" INGRESS_MODE="${INGRESS_MODE:-hostport}" DNS_MODE="${DNS_MODE:-hosts}" # The newest node image versions.env pins, found rather than restated, so # bumping the pins moves the default with them. if [ -z "${K8S_VERSION:-}" ]; then K8S_VERSION=$(compgen -v NODE_IMAGE_v | sort -V | tail -1) K8S_VERSION="${K8S_VERSION#NODE_IMAGE_}" fi # Identity follows the FOLDER, so copying this directory somewhere else and # renaming it yields a distinct environment with no further edits. Without # this, two copies would share one cluster and `make cluster down` in either # would destroy the other's. CLUSTER="${CLUSTER:-$(default_cluster_name)}" KUBECONTEXT="kind-${CLUSTER}" # Host ports are a single shared namespace, so unlike the cluster name they # cannot just follow the directory — they have to be spread out. Anything # already set (ctrl/.env, a profile, the command line) wins; only the gaps # are filled. See ports.sh for the reasoning. local base; base=$(derive_port_base "$CLUSTER") HTTP_PORT="${HTTP_PORT:-$base}" HTTPS_PORT="${HTTPS_PORT:-$((base + 1))}" 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:-}" if [ -z "$NODE_IMAGE" ]; then echo "K8S_VERSION='${K8S_VERSION}' has no NODE_IMAGE_${K8S_VERSION} in versions.env" >&2 exit 1 fi # The cluster's shape is a file in ctrl/k8s/, named by the profile. Adding a # shape is adding a file; there is no dispatcher to edit. # # A host that needs its own shape — extra port mappings, more nodes — passes # an absolute path instead, and rig renders it exactly like one of its own: # ${CLUSTER} and ${NODE_IMAGE} are substituted either way. The shape stays in # the host's tree, because what a host's cluster needs is the host's business; # rig only knows how to build whatever it is handed. KIND_CONFIG="${KIND_CONFIG:-kind-config.yaml.tpl}" case "$KIND_CONFIG" in /*) KIND_CONFIG_PATH="$KIND_CONFIG"; KIND_CONFIG_SHOWN="$KIND_CONFIG" ;; *) KIND_CONFIG_PATH="./k8s/${KIND_CONFIG}"; KIND_CONFIG_SHOWN="ctrl/k8s/${KIND_CONFIG}" ;; esac if [ ! -f "$KIND_CONFIG_PATH" ]; then echo "no such cluster shape: ${KIND_CONFIG_SHOWN}" >&2 echo "rig's own: $(ls k8s/kind-config*.yaml.tpl 2>/dev/null | xargs -n1 basename | tr '\n' ' ')" >&2 echo "or pass an absolute path to a shape of your own" >&2 exit 1 fi # Read the shape back out of the YAML rather than trusting a profile to # restate it. check.sh sizes the memory warning on NODES, and cluster.sh # prints AUDIT before spending minutes building something that cannot be # changed afterwards — both would mislead if the numbers drifted. NODES=$(grep -c '^ - role:' "$KIND_CONFIG_PATH") if grep -q 'audit-policy-file' "$KIND_CONFIG_PATH"; then AUDIT=on; else AUDIT=off; fi # What one node costs, measured rather than guessed. On 2026-09-11 a minimal # control-plane node ran at 620 MiB idle and ~728 MiB with a small mock, plus # 16 MiB for the local registry — ~745 MiB of working set. 800 rounds that up, # and agrees with the 800 MB observed independently on a larger rig. Worker # nodes carry no etcd or apiserver and are lighter, so for a multi-node shape # this errs high. It is the cluster alone: whatever you deploy comes on top. # # Here rather than in check.sh because the memory tool and every standalone # kit need the same figure. NODE_MB=800 } # Render a cluster shape to stdout. sed rather than envsubst: envsubst is # gettext-base, absent from a minimal Debian, and Docker is meant to be the only # prerequisite. The variable list is explicit so a template cannot quietly start # depending on something the caller does not set. # # hostPath entries are resolved by the HOST dockerd, so HOST_WORKDIR must stay a # host path even when this runs inside the installer container. render_kind_config() { local host_workdir="${HOST_WORKDIR:-$(cd .. && pwd)}" sed -e "s|\${CLUSTER}|${CLUSTER}|g" \ -e "s|\${NODE_IMAGE}|${NODE_IMAGE}|g" \ -e "s|\${HTTP_PORT}|${HTTP_PORT}|g" \ -e "s|\${HOST_WORKDIR}|${host_workdir}|g" \ "$KIND_CONFIG_PATH" } _config_restore() { local line while IFS= read -r line; do if [ -n "$line" ]; then eval "export $line" fi done <<< "$1" # A while loop returns its last body command's status; the trailing empty # line would otherwise make this return 1 and trip `set -e` in the caller. return 0 } # ── what a standalone kit needs to know ──────────────────────────────────── # Two questions the kit generator (ctrl/standalone.sh) asks, so that it never has # to know how configuration is stored. Where profiles live, which files are # layered and what is derived are this file's business and can change freely; # the generator only calls these. # Every configuration rig can be run as, one per line: each profile file, or — # when there are none — `default`, the built-in configuration load_config uses # when no profile is named. Never empty, because rig never needs a profile. config_profiles() { local f found="" for f in ./env.d/*.env; do [ -e "$f" ] || continue f=${f##*/}; echo "${f%.env}"; found=1 done [ -n "$found" ] || echo default } # The resolved configuration, as `declare -p` lines — exactly what load_config # leaves behind, minus the machine-local layer. A kit freezes this in place of # load_config, so it carries rig's decisions and not this machine's secrets. # # config_snapshot that profile, as any machine would resolve it # config_snapshot --current what THIS machine runs: every overridable key as # resolved here, handed back in as if typed on the # command line, over the same portable resolution. # Values derived from those choices follow them; # anything else the local layer set — credentials — # is not carried. config_left_out names it. # # Found by difference, not by a list: whatever load_config sets today, it sets. # A list here would be one more place to forget a variable. config_snapshot() { local _rig_snap_choices if [ "$1" = --current ]; then _rig_snap_choices=$( ( load_config >/dev/null || exit 1 for _rig_snap_n in $CONFIG_OVERRIDABLE; do if [ -n "${!_rig_snap_n+x}" ]; then printf 'export %s=%q\n' "$_rig_snap_n" "${!_rig_snap_n}"; fi done ) ) || return 1 else _rig_snap_choices="export PROFILE=$(printf '%q' "$1")" fi ( # Nothing from the caller's shell may leak into a kit. for _rig_snap_n in $CONFIG_OVERRIDABLE; do unset "$_rig_snap_n"; done declare -A _rig_snap_was=() for _rig_snap_n in $(compgen -v); do _rig_snap_was[$_rig_snap_n]="${!_rig_snap_n-}" done eval "$_rig_snap_choices" RIG_PORTABLE=1 load_config >/dev/null for _rig_snap_n in $(compgen -v); do case "$_rig_snap_n" in _rig_snap_*|RIG_PORTABLE|BASH*|FUNCNAME|PIPESTATUS|LINENO|RANDOM|SRANDOM|\ SECONDS|EPOCH*|HISTCMD|COLUMNS|LINES|PWD|OLDPWD|_|SHLVL|OPTIND|OPTERR) continue ;; esac if [ -z "${_rig_snap_was[$_rig_snap_n]+x}" ] \ || [ "${_rig_snap_was[$_rig_snap_n]}" != "${!_rig_snap_n-}" ]; then declare -p "$_rig_snap_n" fi done ) } # The profile this machine runs, as load_config resolves it here. config_current_profile() { ( load_config >/dev/null && echo "$PROFILE_NAME" ); } # What an export of this machine's configuration does NOT carry, by name only: # keys the machine-local layer sets that are not choices a caller may override. # They are this machine's own — registry and mirror credentials, mostly — so the # target has to be told to supply them. Values are never printed. config_left_out() { [ -f ./.env ] || return 0 local k for k in $(sed -nE 's/^[[:space:]]*(export[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*)=.*/\2/p' ./.env | sort -u); do case " $(echo $CONFIG_OVERRIDABLE) " in *" $k "*) ;; *) echo "$k" ;; esac done } # A replacement for load_config with a resolution frozen in (a profile, or # --current — see config_snapshot), printed # as a function definition for a standalone kit to carry. The generator embeds # whatever this prints and interprets none of it, so what "frozen" means stays # rig's decision. # # It keeps load_config's one stated rule: the caller's env wins for anything in # CONFIG_OVERRIDABLE. A kit therefore behaves like rig — `OUT_BIN=... rigdeps.sh` # still works — rather than like a copy with everything pinned. # # What freezing does give up, knowingly: values DERIVED from an overridable one # are fixed at generation. Override CLUSTER and the ports stay the ones derived # for the original name. Re-deriving would mean carrying the layering itself, # which is exactly what a kit exists not to need. config_freeze() { local snap snap=$(config_snapshot "$1") || return 1 cat <<'EOF' load_config() { local k saved="" for k in $CONFIG_OVERRIDABLE; do if [ -n "${!k+x}" ]; then saved+="$k=$(printf '%q' "${!k}")"$'\n'; fi done EOF printf '%s\n' "$snap" | sed -E 's/^declare --* / declare -g /; s/^declare -([a-zA-Z]+) / declare -g\1 /' cat <<'EOF' _config_restore "$saved" } EOF }