Files
soleprint/rig/ctrl/lib/config.sh
2026-09-17 00:39:00 -03:00

305 lines
14 KiB
Bash

# 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/<profile> addons, registry — OPTIONAL, examples ship as *.env.example
# ctrl/.env machine-local values and secrets (gitignored)
# the caller's env `make cluster up PROFILE=<name>` (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 is deliberately NOT here: it is read back out of the kind config below,
# so the file is the one place that decides it.
#
# 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/<name>, 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 is one file: k8s/kind-config.yaml.tpl. To change it, edit it.
# KIND_CONFIG is only "use this file instead", for a project that builds its
# own cluster through rig (a path relative to ctrl/, or absolute).
KIND_CONFIG="${KIND_CONFIG:-./k8s/kind-config.yaml.tpl}"
if [ ! -f "$KIND_CONFIG" ]; then
echo "no kind config at KIND_CONFIG=${KIND_CONFIG}" >&2
exit 1
fi
# Read the node count back out of the file rather than restating it:
# check.sh and the memory tool size their budget on NODES.
NODES=$(grep -c '^ - role:' "$KIND_CONFIG")
# 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 the kind config 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"
}
_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 <profile> 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
}