This commit is contained in:
2026-08-20 11:24:42 -03:00
parent a65c92257d
commit 83b6cbebe3
64 changed files with 7688 additions and 0 deletions

50
rig/ctrl/.env.example Normal file
View File

@@ -0,0 +1,50 @@
# Machine-local config. Copy to ctrl/.env (gitignored) and edit.
# Cluster SHAPE lives in ctrl/env.d/<profile>.env — not here.
# The architecture MODEL lives in arch/<name>.json — not here either.
# Which profile in ctrl/env.d/ to build. minimal | client | offline
PROFILE=minimal
# Cluster name; the kubectl context becomes kind-<CLUSTER>.
# LEAVE THIS UNSET unless you need a name that differs from the directory —
# it defaults to this folder's name, which is what makes the folder copyable:
# copy it, rename it, and you get a separate environment with no edits.
# CLUSTER=
# Host ports. LEAVE UNSET — they derive from the directory name so several
# environments coexist without negotiating (see ctrl/ports.sh). `make ports`
# shows this environment's block; `make ports persist` writes it here so it stops
# being derived and becomes fixed. Set a value only to override.
# HTTP_PORT=
# HTTPS_PORT=
# TILT_PORT=
# REGISTRY_PORT=
# Where the application manifests live. The real ones are expected to be
# versioned separately from this installer — they change on a different cadence,
# by different people. Repoint this at their repo and rig stops owning them:
# MANIFESTS_DIR=../platform-manifests/overlays/dev
MANIFESTS_DIR=ctrl/k8s/overlays/dev
# Where the wizard fetches the pinned binaries from.
# upstream GitHub releases / dl.k8s.io (needs internet)
# artifactory a generic repo — what a locked-down client usually allows
# baked already inside the wizard image; no network at all
DEPS_SOURCE=upstream
DEPS_ARTIFACTORY_URL=
# --- Registry -------------------------------------------------------------
# Mode comes from the profile (REGISTRY_MODE). These are the secrets it needs.
# Required for mirror/remote:
REGISTRY_REMOTE_URL=
REGISTRY_USER=
REGISTRY_PASSWORD=
# Corporate root CA, if Artifactory is fronted by an internal CA (it usually is).
# Trust has to reach THREE places and nothing does it for you: the host docker
# daemon, every kind node's containerd, and any in-cluster client. registry.sh
# handles the first two; station.sh reports when it's configured but not trusted.
# Symptom when missing: x509: certificate signed by unknown authority
REGISTRY_CA_FILE=
# (The local registry's host port is part of the derived block above.)

View File

@@ -0,0 +1,46 @@
# The installation wizard. It does NOT run the cluster — it installs a toolchain
# onto the host and gets out of the way.
#
# This exists to kill a bootstrap paradox: a plain bash installer needs curl, jq
# and sha256sum to already be present, and a minimal Debian has none of them.
# The wizard carries its own toolchain, so the only host prerequisite is Docker.
#
# Two variants from one file:
# docker build -f ctrl/Dockerfile.wizard --target wizard -t <slug>-wizard .
# docker build -f ctrl/Dockerfile.wizard --target wizard-full -t <slug>-wizard:full .
#
# wizard-full bakes every pinned binary in at build time. `docker save` it and
# you have the whole installer as one file to carry into an air-gapped network.
FROM debian:trixie-slim AS wizard
# ca-certificates + curl: fetch and verify. graphviz + python3: render diagrams
# and validate the arch model, so the host never needs an apt package.
#
# docker-cli, NOT docker.io: we only ever talk to the host's daemon through the
# mounted socket, and under --no-install-recommends the docker.io package ships
# docker-init without the actual `docker` binary.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl jq graphviz python3 docker-cli \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /work
COPY ctrl/versions.env /work/ctrl/versions.env
COPY ctrl/wizard.sh /work/ctrl/wizard.sh
RUN chmod +x /work/ctrl/wizard.sh
# Defaults; every one is overridable with -e at run time.
ENV DEPS_SOURCE=upstream \
OUT_BIN=/out/bin \
HOST_ROOT=/host
ENTRYPOINT ["/work/ctrl/wizard.sh"]
CMD ["install"]
# ---------------------------------------------------------------------------
# wizard-full — same wizard, binaries baked in, works with no network at all.
FROM wizard AS wizard-full
RUN /work/ctrl/wizard.sh fetch --to /opt/rig/bin
ENV DEPS_SOURCE=baked \
BAKED_BIN=/opt/rig/bin

39
rig/ctrl/addons.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Install the addons the active profile asked for, in the order listed.
# Each addon is its own idempotent script in ctrl/addons/ — adding one is adding
# a file, not editing a dispatcher.
#
# Usage: addons.sh install | list
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
load_config
install() {
if [ -z "${ADDONS// /}" ]; then
echo "no addons in profile '$PROFILE_NAME'"
return
fi
local a
for a in $ADDONS; do
if [ ! -f "addons/${a}.sh" ]; then
echo "no such addon: addons/${a}.sh" >&2
exit 1
fi
echo "addon: $a"
bash "addons/${a}.sh"
done
}
list() {
echo "profile '$PROFILE_NAME' wants: ${ADDONS:-none}"
echo "available:"
ls addons/*.sh 2>/dev/null | xargs -n1 basename | sed 's/\.sh$//' | sed 's/^/ /'
}
case "${1:-list}" in
install) install ;;
list) list ;;
*) echo "usage: $0 [install|list]" >&2; exit 1 ;;
esac

115
rig/ctrl/addons/airflow.sh Executable file
View File

@@ -0,0 +1,115 @@
#!/usr/bin/env bash
# Apache Airflow — the cluster half of soleprint's airflow cabinet.
#
# Airflow needs a metadata database before it will start at all, so this refuses
# rather than rolls a pod that will CrashLoopBackOff while the real problem
# (postgres missing from ADDONS) stays invisible in the logs.
#
# One pod on `standalone`, matching the compose cabinet: migration, admin user,
# scheduler and webserver in a single container. The official chart's five
# deployments model an installation; a room switching this on wants pipelines.
set -euo pipefail
cd "$(dirname "$0")/.."
source ./lib/config.sh
load_config
K="kubectl --context ${KUBECONTEXT}"
NS="${DATA_NAMESPACE:-data}"
if ! $K get deployment -n "$NS" postgres >/dev/null 2>&1; then
echo " ! airflow needs the postgres addon, and it is not installed" >&2
echo " add it before airflow in the profile's ADDONS:" >&2
echo " ADDONS=\"... postgres airflow\"" >&2
exit 1
fi
# Reuse the credential postgres generated rather than storing a second copy.
db_user=$($K get secret -n "$NS" postgres -o jsonpath='{.data.POSTGRES_USER}' | base64 -d)
db_pass=$($K get secret -n "$NS" postgres -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d)
db_name=$($K get secret -n "$NS" postgres -o jsonpath='{.data.POSTGRES_DB}' | base64 -d)
if $K get secret -n "$NS" airflow >/dev/null 2>&1; then
echo " secret exists, keeping the current admin password and fernet key"
else
admin_password=$(head -c 18 /dev/urandom | base64 | tr -d '/+=' | head -c 24)
# Airflow requires a 32-byte urlsafe-base64 key; without a fixed one every
# restart invalidates every stored connection.
fernet_key=$(head -c 32 /dev/urandom | base64 | tr '+/' '-_')
$K create secret generic airflow -n "$NS" \
--from-literal=ADMIN_USER="${AIRFLOW_ADMIN_USER:-admin}" \
--from-literal=ADMIN_PASSWORD="$admin_password" \
--from-literal=FERNET_KEY="$fernet_key" \
--from-literal=SQL_ALCHEMY_CONN="postgresql+psycopg2://${db_user}:${db_pass}@postgres:5432/${db_name}" \
>/dev/null
echo " generated an admin password (read it back with the command below)"
fi
echo " applying manifests"
$K apply -n "$NS" -f - >/dev/null <<YAML
apiVersion: v1
kind: Service
metadata:
name: airflow
spec:
selector:
app: airflow
ports:
- port: 8080
targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: airflow
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: airflow
template:
metadata:
labels:
app: airflow
spec:
containers:
- name: airflow
image: ${AIRFLOW_IMAGE}
args: ["standalone"]
env:
- name: AIRFLOW__CORE__EXECUTOR
value: LocalExecutor
- name: AIRFLOW__CORE__LOAD_EXAMPLES
value: "false"
- name: AIRFLOW__DATABASE__SQL_ALCHEMY_CONN
valueFrom:
secretKeyRef: {name: airflow, key: SQL_ALCHEMY_CONN}
- name: AIRFLOW__CORE__FERNET_KEY
valueFrom:
secretKeyRef: {name: airflow, key: FERNET_KEY}
- name: _AIRFLOW_WWW_USER_USERNAME
valueFrom:
secretKeyRef: {name: airflow, key: ADMIN_USER}
- name: _AIRFLOW_WWW_USER_PASSWORD
valueFrom:
secretKeyRef: {name: airflow, key: ADMIN_PASSWORD}
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /health
port: 8080
# First boot runs the whole migration before it serves anything.
initialDelaySeconds: 60
periodSeconds: 15
failureThreshold: 20
YAML
echo " waiting for airflow (the first boot migrates the database, so this is slow)..."
$K rollout status deployment/airflow -n "$NS" --timeout=600s
echo " in-cluster: http://airflow.${NS}.svc.cluster.local:8080"
echo " reach it: kubectl --context ${KUBECONTEXT} -n ${NS} port-forward svc/airflow 8080:8080"
echo " password: kubectl --context ${KUBECONTEXT} -n ${NS} get secret airflow -o jsonpath='{.data.ADMIN_PASSWORD}' | base64 -d"

65
rig/ctrl/addons/cert-manager.sh Executable file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# cert-manager plus a self-signed cluster issuer.
#
# In a regulated estate almost everything is TLS, so the interesting question
# during onboarding is "does this service present a cert my client trusts" — not
# "can I reach a public ACME server". A local CA answers that offline, which is
# also what makes the air-gapped profile usable.
set -euo pipefail
cd "$(dirname "$0")/.."
source ./lib/config.sh
load_config
K="kubectl --context ${KUBECONTEXT}"
if $K get deployment -n cert-manager cert-manager >/dev/null 2>&1; then
echo " already installed"
else
$K apply -f "https://github.com/cert-manager/cert-manager/releases/download/${CERT_MANAGER_VERSION}/cert-manager.yaml"
fi
echo " waiting for cert-manager..."
$K wait --namespace cert-manager \
--for=condition=ready pod --selector=app.kubernetes.io/instance=cert-manager \
--timeout=240s
# A self-signed root, then a CA issuer chained off it. Workloads reference
# ClusterIssuer/local-ca and get a cert from a CA you can actually distribute.
echo " creating local CA issuer"
$K apply -f - <<'YAML' >/dev/null
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: selfsigned-root
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: local-ca
namespace: cert-manager
spec:
isCA: true
commonName: rig-local-ca
secretName: local-ca-key-pair
duration: 87600h
privateKey:
algorithm: ECDSA
size: 256
issuerRef:
name: selfsigned-root
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: local-ca
spec:
ca:
secretName: local-ca-key-pair
YAML
echo " export the CA for your browser/client with:"
echo " kubectl --context ${KUBECONTEXT} -n cert-manager get secret local-ca-key-pair -o jsonpath='{.data.tls\\.crt}' | base64 -d"

103
rig/ctrl/addons/metallb.sh Executable file
View File

@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# MetalLB — makes `Service type: LoadBalancer` actually get an address.
#
# Why it matters here: real manifests use LoadBalancer, because a real cluster
# has one. On a bare kind cluster those Services sit at EXTERNAL-IP <pending>
# forever with no error anywhere — the deployment looks fine and simply is not
# reachable. Without this, every such Service has to be edited to NodePort,
# which means the local manifests stop matching the ones being modelled.
#
# The address pool is derived from the kind Docker network at install time, not
# hardcoded: Docker picks that subnet, it differs between machines, and a pool
# outside it is silently unroutable.
set -euo pipefail
cd "$(dirname "$0")/.."
source ./lib/config.sh
load_config
K="kubectl --context ${KUBECONTEXT}"
# ── work out an address range ──────────────────────────────────────────────
# kind hands node addresses out from the bottom of the subnet, so the top is
# free. Taking a slice there avoids collisions with current and future nodes.
subnet=$(docker network inspect kind \
-f '{{range .IPAM.Config}}{{.Subnet}} {{end}}' 2>/dev/null \
| tr ' ' '\n' | grep -E '^[0-9]+\.' | head -1)
if [ -z "$subnet" ]; then
echo " ! could not read the kind Docker network subnet" >&2
echo " (is the cluster up? MetalLB needs the network to exist first)" >&2
exit 1
fi
base="${subnet%/*}"; prefix="${subnet#*/}"
o1=$(echo "$base" | cut -d. -f1); o2=$(echo "$base" | cut -d. -f2)
o3=$(echo "$base" | cut -d. -f3)
case "$prefix" in
16) pool_start="${o1}.${o2}.255.200"; pool_end="${o1}.${o2}.255.250" ;;
24) pool_start="${o1}.${o2}.${o3}.200"; pool_end="${o1}.${o2}.${o3}.250" ;;
*)
# Guessing a range inside an unexpected prefix risks handing out
# addresses that belong to something else. Say so instead.
echo " ! kind network is $subnet — only /16 and /24 are handled" >&2
echo " set the pool by hand in ctrl/addons/metallb.sh" >&2
exit 1
;;
esac
echo " kind network $subnet → pool ${pool_start}-${pool_end}"
# ── install ────────────────────────────────────────────────────────────────
if $K get deployment -n metallb-system controller >/dev/null 2>&1; then
echo " already installed"
else
$K apply -f "https://raw.githubusercontent.com/metallb/metallb/${METALLB_VERSION}/config/manifests/metallb-native.yaml"
fi
# `kubectl wait` on a selector errors out immediately when nothing matches yet,
# and right after apply the ReplicaSet has not created the pod — so it loses a
# race it looks like it should win. `rollout status` waits for the Deployment
# itself and handles the not-yet-created case.
echo " waiting for the controller..."
$K rollout status deployment/controller -n metallb-system --timeout=240s
$K rollout status daemonset/speaker -n metallb-system --timeout=240s
# The webhook rejects IPAddressPools until it is actually serving, and it comes
# up a moment after the pod is Ready — so retry rather than fail the whole run
# on a race that resolves itself in seconds.
echo " configuring the address pool"
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if $K apply -f - >/dev/null 2>&1 <<YAML
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: default
namespace: metallb-system
spec:
addresses:
- ${pool_start}-${pool_end}
---
# Layer 2 mode: one node answers ARP for each address. No BGP peer needed, which
# is what makes this work on a laptop.
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: default
namespace: metallb-system
spec:
ipAddressPools:
- default
YAML
then
echo " pool ready: ${pool_start}-${pool_end}"
exit 0
fi
sleep 3
done
echo " ! the pool was rejected after 10 attempts — is the webhook up?" >&2
$K get pods -n metallb-system >&2
exit 1

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# metrics-server — makes `kubectl top` work.
#
# kind nodes serve kubelet metrics over a self-signed cert, so the standard
# manifest never becomes ready without --kubelet-insecure-tls. That is fine here
# (it is a local cluster) and is the single most common reason metrics-server
# sits at 0/1 on kind.
set -euo pipefail
cd "$(dirname "$0")/.."
source ./lib/config.sh
load_config
K="kubectl --context ${KUBECONTEXT}"
if ! $K get deployment -n kube-system metrics-server >/dev/null 2>&1; then
$K apply -f "https://github.com/kubernetes-sigs/metrics-server/releases/download/${METRICS_SERVER_VERSION}/components.yaml"
fi
$K patch deployment metrics-server -n kube-system --type=json \
-p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]' \
>/dev/null 2>&1 || true
echo " waiting for metrics-server..."
$K rollout status deployment/metrics-server -n kube-system --timeout=180s

118
rig/ctrl/addons/postgres.sh Executable file
View File

@@ -0,0 +1,118 @@
#!/usr/bin/env bash
# PostgreSQL — the cluster half of soleprint's postgres cabinet.
#
# A room declares the dependency once, in cfg/<room>/data/cabinets.json. On a
# laptop `build.py` composes it into docker-compose.yml; here it becomes a pod,
# so the same declaration works either way and nothing has to be remembered
# twice.
#
# Plain manifests rather than a helm chart, matching the other addons: a chart
# repo is a network dependency, and the offline profile exists precisely so
# there is a path with none. The image is pinned in ctrl/versions.env and can be
# preloaded into a local registry like every other image here.
#
# One replica on a PVC. This models a dependency for local work, not a
# highly-available database, and pretending otherwise on a kind node would be a
# more elaborate lie rather than a more useful one.
set -euo pipefail
cd "$(dirname "$0")/.."
source ./lib/config.sh
load_config
K="kubectl --context ${KUBECONTEXT}"
NS="${DATA_NAMESPACE:-data}"
$K get namespace "$NS" >/dev/null 2>&1 || $K create namespace "$NS"
# The password is generated once and then left alone, so re-running this does
# not rotate the credential out from under whatever is already connected.
if $K get secret -n "$NS" postgres >/dev/null 2>&1; then
echo " secret exists, keeping the current password"
else
password=$(head -c 18 /dev/urandom | base64 | tr -d '/+=' | head -c 24)
$K create secret generic postgres -n "$NS" \
--from-literal=POSTGRES_DB="${POSTGRES_DB:-soleprint}" \
--from-literal=POSTGRES_USER="${POSTGRES_USER:-soleprint}" \
--from-literal=POSTGRES_PASSWORD="$password" >/dev/null
echo " generated a password (read it back with the command printed below)"
fi
echo " applying manifests"
$K apply -n "$NS" -f - >/dev/null <<YAML
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-data
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: ${POSTGRES_STORAGE:-2Gi}
---
apiVersion: v1
kind: Service
metadata:
name: postgres
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
spec:
replicas: 1
# One volume, one writer. Rolling would start a second pod against the same
# PVC before the first exits, and Postgres refuses to share a data directory.
strategy:
type: Recreate
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: ${POSTGRES_IMAGE}
envFrom:
- secretRef:
name: postgres
env:
# The image initialises into the volume root otherwise, and a
# lost+found from the PVC makes it refuse to initdb.
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
ports:
- containerPort: 5432
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
readinessProbe:
exec:
command: ["sh", "-c", "pg_isready -U \$POSTGRES_USER -d \$POSTGRES_DB"]
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
exec:
command: ["sh", "-c", "pg_isready -U \$POSTGRES_USER -d \$POSTGRES_DB"]
initialDelaySeconds: 30
periodSeconds: 15
volumes:
- name: data
persistentVolumeClaim:
claimName: postgres-data
YAML
echo " waiting for postgres..."
$K rollout status deployment/postgres -n "$NS" --timeout=240s
echo " in-cluster: postgres.${NS}.svc.cluster.local:5432"
echo " password: kubectl --context ${KUBECONTEXT} -n ${NS} get secret postgres -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d"

60
rig/ctrl/addons/redis.sh Executable file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Redis — the cluster half of soleprint's redis cabinet.
#
# Cache, and the broker anything queue-shaped runs on. No persistence: a broker
# that loses its queue on restart is the honest local model, and a PVC here buys
# nothing but a volume to clean up.
set -euo pipefail
cd "$(dirname "$0")/.."
source ./lib/config.sh
load_config
K="kubectl --context ${KUBECONTEXT}"
NS="${DATA_NAMESPACE:-data}"
$K get namespace "$NS" >/dev/null 2>&1 || $K create namespace "$NS"
echo " applying manifests"
$K apply -n "$NS" -f - >/dev/null <<YAML
apiVersion: v1
kind: Service
metadata:
name: redis
spec:
selector:
app: redis
ports:
- port: 6379
targetPort: 6379
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: ${REDIS_IMAGE}
ports:
- containerPort: 6379
readinessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 3
periodSeconds: 5
YAML
echo " waiting for redis..."
$K rollout status deployment/redis -n "$NS" --timeout=180s
echo " in-cluster: redis://redis.${NS}.svc.cluster.local:6379/0"

149
rig/ctrl/cluster.sh Executable file
View File

@@ -0,0 +1,149 @@
#!/usr/bin/env bash
# Cluster lifecycle, plus what else is running on this machine.
#
# `list` and `free` live here rather than in a separate script because a
# near-identical second name (cluster / clusters) is a trap — you reach for one
# and get the other. One target, one file, unambiguous subcommands.
#
# "Idempotent" here means CONVERGENT, not "exits early if the cluster exists".
# That distinction matters: an interrupted first run can leave a cluster created
# but not finished, and returning early on the re-run would strand it there.
# The create step is conditional; every step after it always runs, and each one
# is individually idempotent.
#
# Usage: cluster.sh up | down | reset | list | free
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
load_config
up() {
if kind get clusters 2>/dev/null | grep -qx "$CLUSTER"; then
echo "cluster '$CLUSTER' exists — converging"
else
# Say what this profile locks in BEFORE spending minutes building it:
# the audit policy is an apiserver flag and cannot be changed later.
echo "creating cluster '$CLUSTER' from profile '$PROFILE_NAME'"
echo " shape ctrl/k8s/$KIND_CONFIG"
echo " nodes $NODES"
echo " image $NODE_IMAGE"
echo " audit $AUDIT"
echo " ingress $INGRESS_MODE"
echo " registry $REGISTRY_MODE"
echo " (audit is fixed at creation — 'make cluster reset' to change it)"
echo
render_kind_config | kind create cluster --config -
fi
# The cluster can exist while its context does not — a reset or a switched
# KUBECONFIG loses it, and then nothing works despite a healthy cluster.
if ! kubectl config get-contexts -o name 2>/dev/null | grep -qx "$KUBECONTEXT"; then
echo "context '$KUBECONTEXT' missing from kubeconfig — re-exporting"
kind export kubeconfig --name "$CLUSTER"
fi
kubectl config use-context "$KUBECONTEXT" >/dev/null
bash registry.sh up
if [ -n "${ADDONS// /}" ]; then
bash addons.sh install
fi
echo
echo "cluster '$CLUSTER' ready (context $KUBECONTEXT)"
}
down() {
# The registry is a standalone container outside the cluster; take it down
# first so a reset doesn't leave it orphaned and holding a port.
bash registry.sh down || true
if kind get clusters 2>/dev/null | grep -qx "$CLUSTER"; then
echo "deleting cluster '$CLUSTER'..."
kind delete cluster --name "$CLUSTER"
else
echo "no cluster '$CLUSTER' to delete"
fi
}
# The escape hatch for a wedged cluster, and the only way to change a
# creation-time setting such as the audit policy.
reset() {
down
echo
up
}
# ── the whole machine ──────────────────────────────────────────────────────
# Every cluster is a running container tree whether or not you are using it, and
# an idle one is the usual reason a new one will not fit.
list() {
local total avail
total=$(awk '/^MemTotal:/{printf "%.1f", $2/1024/1024}' /proc/meminfo)
avail=$(awk '/^MemAvailable:/{printf "%.1f", $2/1024/1024}' /proc/meminfo)
echo "memory: ${avail} GB available of ${total} GB"
echo
local names; names=$(kind get clusters 2>/dev/null || true)
if [ -z "$names" ]; then
echo "no clusters"
return
fi
printf "%-16s %-10s %8s %6s %-13s %s\n" CLUSTER STATE MEM NODES PORTS ""
local c nodes state mem base
for c in $names; do
nodes=$(docker ps -a --filter "label=io.x-k8s.kind.cluster=$c" --format '{{.Names}}' | wc -l)
state=$(docker inspect -f '{{.State.Status}}' "${c}-control-plane" 2>/dev/null || echo unknown)
if [ "$state" = "running" ]; then
mem=$(docker stats --no-stream --format '{{.MemUsage}}' \
$(docker ps --filter "label=io.x-k8s.kind.cluster=$c" -q) 2>/dev/null \
| awk '{gsub(/GiB/,"");gsub(/MiB/,"e-3");s+=$1} END {printf "%.1fG", s}')
else
mem="-"
fi
# A cluster's name is its directory slug, so its port block is derivable
# here without reading that directory's config.
base=$(derive_port_base "$c")
printf "%-16s %-10s %8s %6s %-13s %s\n" "$c" "$state" "$mem" "$nodes" \
"${base}-$((base + 3))" \
"$([ "$c" = "$CLUSTER" ] && echo "<- this one")"
done
}
# Stop the OTHER clusters to free memory. Stops, never deletes — a stopped
# cluster restarts with `docker start`, so nothing is lost.
free() {
local targets=("$@")
if [ ${#targets[@]} -eq 0 ]; then
mapfile -t targets < <(kind get clusters 2>/dev/null | grep -vx "$CLUSTER" || true)
fi
if [ ${#targets[@]} -eq 0 ]; then
echo "nothing to stop"
return
fi
local c ids
for c in "${targets[@]}"; do
ids=$(docker ps --filter "label=io.x-k8s.kind.cluster=$c" -q)
if [ -z "$ids" ]; then
echo "cluster '$c' is not running"
continue
fi
echo "stopping '$c' (restart with: docker start \$(docker ps -aq -f label=io.x-k8s.kind.cluster=$c))"
# shellcheck disable=SC2086
docker stop $ids >/dev/null
done
}
case "${1:-up}" in
up) up ;;
down) down ;;
reset) reset ;;
list) list ;;
free) shift; free "$@" ;;
*) echo "usage: $0 [up|down|reset|list|free]" >&2; exit 1 ;;
esac

272
rig/ctrl/dockerhost.sh Executable file
View File

@@ -0,0 +1,272 @@
#!/usr/bin/env bash
# Share ONE Docker daemon across WSL distros, instead of running one per distro.
#
# Why this exists
# ---------------
# WSL2 distros share a kernel and a network stack. Two dockerd instances then
# contend over docker0 and iptables, which can disturb the daemon you actually
# depend on. Docker Desktop avoids this by running a single daemon in a
# dedicated distro and sharing its socket — this is the same idea, without
# Docker Desktop.
#
# So a throwaway rig box does NOT install Docker. It borrows the daemon from
# whichever distro is the designated host. That also makes the test more honest:
# rig never installs Docker anyway — Docker is its documented prerequisite.
#
# How
# ---
# /mnt/wsl is a tmpfs with `shared` mount propagation, visible to every distro
# in the WSL VM. The owning distro exposes its socket there; guests point
# DOCKER_HOST at it. Two ways, with different costs:
#
# share bind-mount the existing socket onto the shared tmpfs.
# Instant, and dockerd is NEVER restarted. Lasts until the
# next WSL shutdown.
# share --persist additionally install a systemd drop-in so dockerd listens
# there itself. Survives restarts, but requires one Docker
# restart now — which stops every container that has no
# restart policy, since live-restore is off by default.
#
# The bind mount is the default precisely because the persistent version's cost
# is paid on a machine that is already working.
#
# Reversibility is the whole design
# ---------------------------------
# `unshare` removes the bind mount (no restart) and, if present, the drop-in.
# The original systemd unit is never edited — only an additive drop-in file is
# ever created — so undoing is deletion, not repair. `status` always states
# which of the three roles a distro is in, in those words.
#
# Nothing here runs automatically. It does nothing until invoked.
#
# Usage:
# dockerhost.sh status # which distro owns Docker; what this one uses
# dockerhost.sh share # share it (bind mount, no daemon restart)
# dockerhost.sh share --persist # ...and survive WSL restarts (restarts Docker)
# dockerhost.sh unshare # undo it; this distro owns its Docker again
# dockerhost.sh use [--persist] # point THIS distro at the shared socket
set -euo pipefail
SHARED_DIR=/mnt/wsl/shared-docker
SHARED_SOCK="$SHARED_DIR/docker.sock"
OWNER_FILE="$SHARED_DIR/OWNER"
DROPIN=/etc/systemd/system/docker.service.d/10-rig-shared-socket.conf
PROFILE_D=/etc/profile.d/rig-docker-host.sh
distro_name() { echo "${WSL_DISTRO_NAME:-$(hostname)}"; }
require_wsl() {
grep -qi microsoft /proc/version 2>/dev/null && return 0
echo "dockerhost is WSL-only: it relies on /mnt/wsl being shared between distros." >&2
exit 1
}
# ── status ─────────────────────────────────────────────────────────────────
status() {
require_wsl
echo "distro $(distro_name)"
if [ -f "$DROPIN" ] || mountpoint -q "$SHARED_SOCK" 2>/dev/null; then
echo "role SHARING — this distro's Docker is offered to other distros"
elif [ -n "${DOCKER_HOST:-}" ] && [ "${DOCKER_HOST}" = "unix://$SHARED_SOCK" ]; then
echo "role BORROWING — using another distro's Docker"
else
echo "role standalone — this WSL installation has the main host Docker"
fi
echo
if [ -S "$SHARED_SOCK" ]; then
echo "shared sock $SHARED_SOCK (present)"
[ -f "$OWNER_FILE" ] && sed 's/^/ /' "$OWNER_FILE"
else
echo "shared sock none — no distro is sharing right now"
fi
echo
echo "DOCKER_HOST ${DOCKER_HOST:-(unset — using /var/run/docker.sock)}"
if command -v docker >/dev/null 2>&1; then
echo "docker $(docker version --format '{{.Server.Version}}' 2>/dev/null || echo unreachable)"
else
echo "docker cli not installed"
fi
}
# ── share / unshare (run on the host distro) ───────────────────────────────
# Default: expose the EXISTING socket by bind-mounting it onto the shared tmpfs.
# /mnt/wsl has `shared` propagation, so the mount is visible in other distros.
#
# The point of doing it this way is that dockerd is never restarted. Restarting
# it stops every container that has no restart policy (live-restore is off by
# default), which on a working machine means quietly killing whatever you had
# running. Not a trade worth making just to expose a socket.
#
# Cost: a bind mount does not survive a WSL VM shutdown. `--persist` adds the
# systemd drop-in as well, which does survive but needs that one restart.
share_bind() {
mkdir -p "$SHARED_DIR"
chmod 0755 "$SHARED_DIR"
if mountpoint -q "$SHARED_SOCK" 2>/dev/null; then
echo "already bind-mounted at $SHARED_SOCK"
else
[ -S /var/run/docker.sock ] || { echo "no /var/run/docker.sock here" >&2; exit 1; }
# The target must exist as a file for a bind mount onto it.
[ -e "$SHARED_SOCK" ] || : > "$SHARED_SOCK"
mount --bind /var/run/docker.sock "$SHARED_SOCK"
echo "bind-mounted /var/run/docker.sock -> $SHARED_SOCK (no daemon restart)"
fi
cat > "$OWNER_FILE" <<EOF
owner distro: $(distro_name)
docker gid: $(getent group docker | cut -d: -f3)
socket: $SHARED_SOCK
method: bind-mount (until the next WSL shutdown)
EOF
}
share() {
require_wsl
[ "$(id -u)" -eq 0 ] || { echo "run with sudo: sudo bash ctrl/dockerhost.sh share" >&2; exit 1; }
share_bind
if [ "${1:-}" != "--persist" ]; then
echo
echo "This lasts until the next WSL shutdown. To make it survive, re-run with"
echo "--persist — but note that adds a systemd drop-in and RESTARTS Docker,"
echo "which stops any container that has no restart policy."
return 0
fi
if [ -f "$DROPIN" ]; then
echo "drop-in already present — sharing persists across restarts."
return 0
fi
echo
echo "--persist: installing a systemd drop-in and restarting Docker."
echo "Containers without a restart policy will stop and will NOT come back."
docker ps --format ' {{.Names}} restart={{.HostConfig.RestartPolicy.Name}}' 2>/dev/null \
|| docker ps --format ' {{.Names}}' 2>/dev/null || true
echo
local exec_line
exec_line=$(systemctl cat docker.service | grep -m1 '^ExecStart=')
if [ -z "$exec_line" ]; then
echo "could not read docker.service ExecStart — refusing to guess" >&2
exit 1
fi
mkdir -p "$(dirname "$DROPIN")" "$SHARED_DIR"
# Additive only: blank the inherited ExecStart, then restate it verbatim
# with one extra -H. Nothing about the original unit is edited.
cat > "$DROPIN" <<EOF
# Added by rig (ctrl/dockerhost.sh share).
#
# Adds a SECOND listening socket on the WSL-shared tmpfs so other distros can
# use this daemon instead of running their own. The original socket is
# untouched, so this distro behaves exactly as before.
#
# To undo: sudo bash ctrl/dockerhost.sh unshare
[Service]
ExecStartPre=-/bin/mkdir -p $SHARED_DIR
ExecStartPre=-/bin/chmod 0755 $SHARED_DIR
ExecStart=
${exec_line} -H unix://$SHARED_SOCK
EOF
systemctl daemon-reload
systemctl restart docker
# Guests need a group with a MATCHING GID to use the socket; GIDs are not
# consistent across distros, so record ours rather than assume.
cat > "$OWNER_FILE" <<EOF
owner distro: $(distro_name)
docker gid: $(getent group docker | cut -d: -f3)
socket: $SHARED_SOCK
EOF
echo "sharing from '$(distro_name)'"
echo " guests: export DOCKER_HOST=unix://$SHARED_SOCK"
echo " undo: sudo bash ctrl/dockerhost.sh unshare"
echo
echo "NOTE: /mnt/wsl is tmpfs and is cleared when the WSL VM shuts down."
echo " The drop-in recreates the directory on the next Docker start."
}
unshare_() {
require_wsl
[ "$(id -u)" -eq 0 ] || { echo "run with sudo: sudo bash ctrl/dockerhost.sh unshare" >&2; exit 1; }
local did=0
# The bind mount first: undoing it needs no restart, so a plain `share`
# is fully reversible without disturbing anything.
if mountpoint -q "$SHARED_SOCK" 2>/dev/null; then
umount "$SHARED_SOCK"
rm -f "$SHARED_SOCK"
echo " removed the bind mount (no restart needed)"
did=1
fi
rm -f "$OWNER_FILE"
rmdir "$SHARED_DIR" 2>/dev/null || true
if [ -f "$DROPIN" ]; then
rm -f "$DROPIN"
rmdir "$(dirname "$DROPIN")" 2>/dev/null || true
systemctl daemon-reload
systemctl restart docker
echo " removed the systemd drop-in and restarted Docker"
did=1
fi
if [ "$did" -eq 0 ]; then
echo "not sharing — this WSL installation already has the main host Docker."
return 0
fi
echo "restored: this WSL installation has the main host Docker again."
echo " (nothing else was changed; the original unit was never edited)"
}
# ── use (run on a guest distro) ────────────────────────────────────────────
use() {
require_wsl
if [ ! -S "$SHARED_SOCK" ]; then
echo "no shared socket at $SHARED_SOCK" >&2
echo "Run 'sudo bash ctrl/dockerhost.sh share' in the distro that owns Docker." >&2
exit 1
fi
# Align the local docker group GID with the owner's, or the socket is
# unreadable here even though it is visible.
if [ -f "$OWNER_FILE" ] && [ "$(id -u)" -eq 0 ]; then
local gid; gid=$(awk '/docker gid:/ {print $3}' "$OWNER_FILE")
if [ -n "$gid" ]; then
if getent group docker >/dev/null; then
[ "$(getent group docker | cut -d: -f3)" = "$gid" ] || groupmod -g "$gid" docker
else
groupadd -g "$gid" docker
fi
fi
fi
if [ "${1:-}" = "--persist" ]; then
[ "$(id -u)" -eq 0 ] || { echo "--persist needs root" >&2; exit 1; }
echo "export DOCKER_HOST=unix://$SHARED_SOCK" > "$PROFILE_D"
echo "persisted in $PROFILE_D"
fi
echo "export DOCKER_HOST=unix://$SHARED_SOCK"
}
case "${1:-status}" in
status) status ;;
share) shift; share "${1:-}" ;;
unshare) unshare_ ;;
use) shift; use "${1:-}" ;;
*) echo "usage: $0 [status|share|unshare|use [--persist]]" >&2; exit 1 ;;
esac

58
rig/ctrl/docs.sh Executable file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Documentation: render the diagrams, and serve the pages.
#
# The docs are the instructions for building the cluster, so they must work
# BEFORE anything else exists. That rules out serving them from the cluster, and
# it rules out python -m http.server too — a minimal Debian has no python3. What
# it does have, by definition, is Docker: the single prerequisite rig already
# demands. So a throwaway nginx container serves a read-only bind mount.
#
# Rendered SVGs are committed alongside their .dot sources for the same reason:
# the pages have to read on a machine with no Graphviz installed.
#
# Usage: docs.sh serve | graphs
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
load_config
REPO="$(cd .. && pwd)"
DOCS_PORT="${DOCS_PORT:-$((HTTP_PORT + 4))}" # +4 sits inside this env's block
serve() {
if [ ! -f "$REPO/docs/index.html" ]; then
echo "no docs/index.html" >&2
exit 1
fi
echo "docs for '$CLUSTER' on http://localhost:${DOCS_PORT}"
echo " (ctrl-c to stop; nothing is installed and nothing persists)"
docker run --rm \
--name "${CLUSTER}-docs" \
-p "${DOCS_PORT}:80" \
-v "$REPO/docs:/usr/share/nginx/html:ro" \
nginx:alpine
}
graphs() {
if ! command -v dot >/dev/null 2>&1; then
echo "graphviz not found — install with: sudo apt install graphviz" >&2
echo "(only needed to re-render; the committed .svg files already work)" >&2
exit 1
fi
shopt -s nullglob
local found=0 f out
for f in "$REPO"/docs/graphs/*.dot; do
out="${f%.dot}.svg"
echo " graphviz $(basename "$f")$(basename "$out")"
dot -Tsvg "$f" -o "$out"
found=1
done
[ "$found" -eq 1 ] || echo " no .dot files in docs/graphs/"
}
case "${1:-serve}" in
serve) serve ;;
graphs) graphs ;;
*) echo "usage: $0 [serve|graphs]" >&2; exit 1 ;;
esac

30
rig/ctrl/env.d/client.env Normal file
View File

@@ -0,0 +1,30 @@
# client — the regulated-estate shape. Multi-node so taints, affinity and
# topology are real; apiserver audit on; images through a pull-through cache of
# the corporate registry.
#
# Costs roughly 4-6 GB. Check `make cluster list` before starting this alongside
# other work — see the memory note in the README.
PROFILE_NAME=client
K8S_VERSION=v1_36
KIND_CONFIG=kind-config.client.yaml.tpl
ADDONS="metallb cert-manager metrics-server"
REGISTRY_MODE=mirror
INGRESS_MODE=hostport
DNS_MODE=hosts
# Ports derive from the directory name by default (see ctrl/ports.sh), so
# several environments run side by side.
#
# Opt in to the real ports below only when this is the ONLY environment and
# nothing else owns :80. They fail to bind otherwise, and docker reports it as an
# opaque "failed to bind host port 0.0.0.0:80/tcp: address already in use"
# halfway through cluster creation. `make station` checks before you spend the
# time. Uncommenting also means only one environment can exist at a time.
# HTTP_PORT=80
# HTTPS_PORT=443
# Set these in ctrl/.env (gitignored), not here:
# REGISTRY_REMOTE_URL=https://artifactory.corp.example/artifactory/api/docker/docker-virtual
# REGISTRY_USER / REGISTRY_PASSWORD
# REGISTRY_CA_FILE=/path/to/corp-root-ca.crt

42
rig/ctrl/env.d/data.env Normal file
View File

@@ -0,0 +1,42 @@
# data — a cluster with the dependency containers a soleprint room asks for.
#
# The point of this profile is that a room declares what it needs once, in
# cfg/<room>/data/cabinets.json, and gets it on either target: `build.py`
# composes those services into docker-compose.yml for a laptop, and the addons
# below install the same ones here. The names match deliberately —
# soleprint/station/cabinets/<name>/cabinet.json carries a `rig_addon` field
# pointing at ctrl/addons/<name>.sh.
#
# Everything lands in the `data` namespace (DATA_NAMESPACE to move it), so
# `make cluster reset` on the app namespace leaves the databases alone.
#
# Costs roughly 2-3 GB with airflow, under 1 without. Airflow's first boot runs
# the whole metadata migration, so expect a few minutes before it is ready.
PROFILE_NAME=data
K8S_VERSION=v1_36
KIND_CONFIG=kind-config.yaml.tpl
# Order matters: addons.sh installs in the order listed, and airflow refuses to
# start without a metadata database, so postgres comes first.
ADDONS="metallb postgres redis airflow"
# local, not none — see minimal.env: `none` has no outward-push guard.
REGISTRY_MODE=local
INGRESS_MODE=hostport
DNS_MODE=hosts
# Namespace for the dependency containers.
DATA_NAMESPACE=data
# Postgres identity. The password is not here: postgres.sh generates one on
# first install and keeps it across re-runs, so re-running the addon never
# rotates the credential out from under whatever is already connected.
POSTGRES_DB=soleprint
POSTGRES_USER=soleprint
POSTGRES_STORAGE=2Gi
AIRFLOW_ADMIN_USER=admin
# Ports derive from the directory name by default — see ctrl/ports.sh. Reach
# the databases with port-forward rather than binding more host ports:
# kubectl -n data port-forward svc/postgres 5432:5432
# kubectl -n data port-forward svc/airflow 8080:8080

View File

@@ -0,0 +1,21 @@
# minimal — the default. One node, no addons, no registry.
# Assumes nothing and boots fast. Start here; move to client.env when you need
# the regulated behaviours.
#
PROFILE_NAME=minimal
K8S_VERSION=v1_36
KIND_CONFIG=kind-config.yaml.tpl
ADDONS=""
# local, not none: `none` leaves the cluster with no registry to push to, and an
# unqualified image name then means docker.io/library/<name>. In a regulated
# estate that is a disclosure risk, not a convenience trade — so the default
# carries the guard even though it costs one container.
REGISTRY_MODE=local
INGRESS_MODE=hostport
DNS_MODE=hosts
# Ports are deliberately NOT set here. They derive from the directory name so
# several environments coexist — see ctrl/ports.sh, and `make ports` to see the
# block this one gets. A fixed default here would collide with whatever else the
# machine happens to be running; 8080 in particular is rarely free.

View File

@@ -0,0 +1,18 @@
# offline — air-gapped. Everything comes from a local registry that was loaded
# ahead of time; nothing reaches the internet. Pair with the wizard-full image
# (DEPS_SOURCE=baked) so the toolchain install is offline too.
#
# The heavier addons are left out to keep first boot viable.
PROFILE_NAME=offline
K8S_VERSION=v1_36
KIND_CONFIG=kind-config.audit.yaml.tpl
ADDONS="metallb"
REGISTRY_MODE=local
INGRESS_MODE=hostport
DNS_MODE=hosts
# Derived from the directory name by default — see ctrl/ports.sh.
# Uncomment for the real ports, but only if this is the only environment.
# HTTP_PORT=80
# HTTPS_PORT=443

15
rig/ctrl/hosts.tmpl Normal file
View File

@@ -0,0 +1,15 @@
# /etc/hosts block for this environment. Rendered by newbox.sh; ${CLUSTER} and
# ${HTTP_PORT} are substituted.
#
# Hostnames are a convenience, not a requirement — every service is reachable at
# localhost:<port> without any of this, which is why DNS is not touched by
# default. Add entries here as the model grows.
#
# On Windows the same block has to go in
# C:\Windows\System32\drivers\etc\hosts for a browser to resolve these. That
# file does NOT support wildcards, so every name must be listed explicitly.
# newbox.sh prints the block for you to paste rather than editing it.
127.0.0.1 ${CLUSTER}.local
127.0.0.1 api.${CLUSTER}.local
127.0.0.1 docs.${CLUSTER}.local

71
rig/ctrl/k8s/README.md Normal file
View File

@@ -0,0 +1,71 @@
# `ctrl/k8s` — cluster shape, and what runs on it
Same layout as every other project here (`unt`, `nvi`, `eth`, `mpr`, and
soleprint's generated rooms): a kind config, a kustomize `base/`, and an
`overlays/dev/` that patches it. See ALL `projects/templates/conventions.md`.
```
kind-config*.yaml.tpl the cluster itself — nodes, ports, audit
base/ the components, as plain manifests
overlays/dev/ how this rig differs from the base
audit-policy.yaml mounted into the apiserver by the audit shapes
```
## Why the cluster config is a template
Every other project checks in a literal `kind-config.yaml`, because there is
exactly one `unt` and one `nvi`. A rig is copied and renamed to make a second
environment, and both the cluster name and the host port block follow the
directory name — so a literal would make every copy collide on both.
`ctrl/cluster.sh` renders it with `sed`, substituting `${CLUSTER}`,
`${NODE_IMAGE}`, `${HTTP_PORT}` and `${HOST_WORKDIR}`. Not `envsubst`: that is
`gettext-base`, which a minimal Debian does not have, and Docker being the only
prerequisite is the one promise rig makes.
**The chosen file is the source of truth for node count and audit.**
`lib/config.sh` reads both back out of it, so a profile names a shape and does
not restate what the YAML already says.
| file | nodes | audit | profiles |
| --- | --- | --- | --- |
| `kind-config.yaml.tpl` | 1 | off | `minimal`, `data` |
| `kind-config.audit.yaml.tpl` | 1 | on | `offline` |
| `kind-config.client.yaml.tpl` | 3 | on | `client` |
A profile picks one with `KIND_CONFIG` in `ctrl/env.d/<profile>.env`. Adding a
shape is adding a file — there is no dispatcher to edit.
Audit is an apiserver flag and therefore fixed at creation: changing it is
`make cluster reset`, not a re-apply.
## `base/` — replace these
**The two components in `base/` are examples, not the system.** They exist so
the real manifests have a shape to be written against.
The real ones are expected to be versioned **separately from the installer**
they change on a different cadence, by different people, under different review.
Point `MANIFESTS_DIR` in `ctrl/.env` at their overlay and rig stops owning them:
```
MANIFESTS_DIR=../platform-manifests/overlays/dev
```
Until then it defaults to `ctrl/k8s/overlays/dev`.
### The three states a component can be in
Switching between them should be a one-line change, never a rewrite. The DNS
name stays the same in every case, so callers never know the difference:
| state | what exists | when |
| --- | --- | --- |
| **real** | an image built from source, hot-reloaded | the one thing you are working on |
| **mock** | a stub returning canned responses (`example-mock.yaml`) | everything else — most of the estate |
| **remote** | no pod at all, just a Service (`example-remote.yaml`) | when the real system is reachable and you want it |
Most components should be **mock**. What has to be faithful is the topology —
names, ports, dependency order, who can reach whom, how it fails. The workloads
are noise, and mocking them is what makes several copies of a large estate fit
on one laptop.

View File

@@ -0,0 +1,44 @@
# Apiserver audit policy. Mounted into the control plane at creation when a
# profile sets AUDIT=on — an apiserver flag, so it cannot be added to a running
# cluster without recreating it.
#
# Deliberately modest: enough to make "who changed what, and when" answerable
# during onboarding without filling the disk. Read the log with:
# docker exec <cluster>-control-plane cat /var/log/kubernetes/audit.log
apiVersion: audit.k8s.io/v1
kind: Policy
# Never log the request body for these — they contain credentials.
omitStages:
- RequestReceived
rules:
# Secrets/configmaps: record that access happened, never the contents.
- level: Metadata
resources:
- group: ""
resources: ["secrets", "configmaps"]
# Authn/authz decisions — the part an auditor actually asks about.
- level: Metadata
nonResourceURLs:
- /apis*
- /api*
# Mutations to workloads and policy: full request, so a diff is reconstructable.
- level: Request
verbs: ["create", "update", "patch", "delete"]
resources:
- group: ""
resources: ["pods", "services", "serviceaccounts", "namespaces"]
- group: "apps"
- group: "networking.k8s.io"
- group: "rbac.authorization.k8s.io"
# Everything else that changes state: metadata only.
- level: Metadata
verbs: ["create", "update", "patch", "delete"]
# Reads are dropped entirely — otherwise controller polling drowns the log.
- level: None
verbs: ["get", "list", "watch"]

View File

@@ -0,0 +1,104 @@
# EXAMPLE — a mocked component. Copy, rename, replace.
#
# A stub that answers on the right name and port with canned responses. No image
# to build: the script is mounted from the ConfigMap, so changing the behaviour
# is a kubectl apply, not a rebuild.
#
# Deliberately boring and readable. This is onboarding material — someone should
# be able to read the generated object and recognise what it is.
apiVersion: v1
kind: ConfigMap
metadata:
name: example-service-stub
data:
# Canned responses by path. Add entries as the contract becomes clear;
# anything unmatched returns 404 so a missing route is visible, not silent.
routes.json: |
{
"/health": {"status": 200, "body": {"status": "ok"}},
"/v1/example": {"status": 200, "body": {"items": [], "mocked": true}}
}
serve.py: |
import json, os
from http.server import BaseHTTPRequestHandler, HTTPServer
ROUTES = json.load(open("/etc/stub/routes.json"))
NAME = os.environ.get("STUB_NAME", "stub")
class H(BaseHTTPRequestHandler):
def do_GET(self):
r = ROUTES.get(self.path)
if r is None:
self.send_response(404)
self.end_headers()
# Say which stub rejected it — with everything mocked, "404"
# alone tells you nothing about where the call actually landed.
self.wfile.write(json.dumps(
{"error": "no canned route", "stub": NAME, "path": self.path}
).encode())
return
body = json.dumps(r["body"]).encode()
self.send_response(r["status"])
self.send_header("Content-Type", "application/json")
self.send_header("X-Mocked-By", NAME)
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
print("%s %s" % (NAME, fmt % args), flush=True)
HTTPServer(("0.0.0.0", 8080), H).serve_forever()
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: example-service
labels:
app: example-service
rig.component/impl: mock # so `kubectl get deploy -L rig.component/impl`
# shows at a glance what is real and what is not
spec:
replicas: 1
selector:
matchLabels:
app: example-service
template:
metadata:
labels:
app: example-service
spec:
containers:
- name: stub
image: python:3.12-slim
command: ["python3", "/etc/stub/serve.py"]
env:
- name: STUB_NAME
value: example-service
ports:
- containerPort: 8080
volumeMounts:
- name: stub
mountPath: /etc/stub
readinessProbe:
httpGet: { path: /health, port: 8080 }
initialDelaySeconds: 2
# Small enough that a whole estate of these fits alongside the real
# thing you are working on.
resources:
requests: { memory: 32Mi, cpu: 10m }
limits: { memory: 64Mi }
volumes:
- name: stub
configMap:
name: example-service-stub
---
apiVersion: v1
kind: Service
metadata:
name: example-service
spec:
selector:
app: example-service
ports:
- port: 80
targetPort: 8080

View File

@@ -0,0 +1,46 @@
# EXAMPLE — a component that is NOT simulated, pointed at the real system.
#
# This is the payoff of keeping the topology honest: there is no pod here at
# all, yet `example-remote.<namespace>.svc.cluster.local` resolves exactly as it
# does when the same component is mocked. Callers are identical in both cases,
# so moving a dependency from mocked to real is a one-line change and nothing
# downstream is touched.
#
# Use this when the real system is reachable and you want it in the loop.
# Note that reachability depends on where you are running: systems restricted to
# a managed workspace will not resolve from a laptop at all, which is the whole
# reason most components should stay mocked.
apiVersion: v1
kind: Service
metadata:
name: example-remote
labels:
rig.component/impl: remote
spec:
type: ExternalName
externalName: real-system.internal.example.com
---
# If the real system has no DNS name — only an IP, which is common for legacy
# hosts — ExternalName cannot express it. Use a bare Service plus manual
# Endpoints instead, and delete the block above.
#
# apiVersion: v1
# kind: Service
# metadata:
# name: example-remote
# labels:
# rig.component/impl: remote
# spec:
# ports:
# - port: 80
# targetPort: 8080
# ---
# apiVersion: v1
# kind: Endpoints
# metadata:
# name: example-remote # must match the Service name exactly
# subsets:
# - addresses:
# - ip: 10.0.0.42
# ports:
# - port: 8080

View File

@@ -0,0 +1,11 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# The namespace every component lands in. The overlay overrides it, so a rig
# modelling two estates can apply the same base twice under different names.
namespace: rig
resources:
- namespace.yaml
- example-mock.yaml
- example-remote.yaml

View File

@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: rig

View File

@@ -0,0 +1,57 @@
# Cluster shape: one node, apiserver audit ON. Used by the `offline` profile.
#
# Audit is an apiserver flag, so it is fixed when the cluster is created —
# changing it means `make cluster reset`, not a re-apply. That is why it is a
# property of the cluster file rather than something switched at runtime.
#
# k8s >= 1.31 uses kubeadm v1beta4, where extraArgs is a LIST of name/value
# pairs. The older map form is silently ignored — it does not error, audit
# simply never turns on.
#
# Substituted by ctrl/cluster.sh: CLUSTER, NODE_IMAGE, HTTP_PORT, HOST_WORKDIR
# (named without the ${...} braces so this line survives the substitution)
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: ${CLUSTER}
containerdConfigPatches:
- |-
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
kubeadmConfigPatches:
- |
kind: ClusterConfiguration
apiServer:
extraArgs:
- name: audit-policy-file
value: /etc/kubernetes/audit/policy.yaml
- name: audit-log-path
value: /var/log/kubernetes/audit.log
- name: audit-log-maxage
value: "7"
extraVolumes:
- name: audit-policy
hostPath: /etc/kubernetes/audit
mountPath: /etc/kubernetes/audit
readOnly: true
- name: audit-log
hostPath: /var/log/kubernetes
mountPath: /var/log/kubernetes
readOnly: false
nodes:
- role: control-plane
image: ${NODE_IMAGE}
# hostPath is resolved by the HOST dockerd, so this must be a host path even
# when cluster.sh runs inside the wizard container. HOST_WORKDIR says where
# this rig lives on the host; bare on a host it is just the repo root.
extraMounts:
- hostPath: ${HOST_WORKDIR}/ctrl/k8s/audit-policy.yaml
containerPath: /etc/kubernetes/audit/policy.yaml
readOnly: true
extraPortMappings:
- containerPort: 30080
hostPort: ${HTTP_PORT}
listenAddress: "0.0.0.0"
protocol: TCP

View File

@@ -0,0 +1,55 @@
# Cluster shape: three nodes, apiserver audit ON. Used by the `client` profile —
# the regulated-estate shape.
#
# Multi-node so taints, affinity and topology spread are real rather than
# vacuously satisfied by a single node. It costs roughly 4-6 GB; run
# `make cluster list` before starting this alongside other work.
#
# Substituted by ctrl/cluster.sh: CLUSTER, NODE_IMAGE, HTTP_PORT, HOST_WORKDIR
# (named without the ${...} braces so this line survives the substitution)
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: ${CLUSTER}
containerdConfigPatches:
- |-
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
kubeadmConfigPatches:
- |
kind: ClusterConfiguration
apiServer:
extraArgs:
- name: audit-policy-file
value: /etc/kubernetes/audit/policy.yaml
- name: audit-log-path
value: /var/log/kubernetes/audit.log
- name: audit-log-maxage
value: "7"
extraVolumes:
- name: audit-policy
hostPath: /etc/kubernetes/audit
mountPath: /etc/kubernetes/audit
readOnly: true
- name: audit-log
hostPath: /var/log/kubernetes
mountPath: /var/log/kubernetes
readOnly: false
nodes:
- role: control-plane
image: ${NODE_IMAGE}
extraMounts:
- hostPath: ${HOST_WORKDIR}/ctrl/k8s/audit-policy.yaml
containerPath: /etc/kubernetes/audit/policy.yaml
readOnly: true
extraPortMappings:
- containerPort: 30080
hostPort: ${HTTP_PORT}
listenAddress: "0.0.0.0"
protocol: TCP
- role: worker
image: ${NODE_IMAGE}
- role: worker
image: ${NODE_IMAGE}

View File

@@ -0,0 +1,36 @@
# Cluster shape: one node, no audit. Used by the `minimal` and `data` profiles.
#
# A TEMPLATE rather than a plain kind-config.yaml because a rig is copied and
# renamed to make a second environment, and both the cluster name and the host
# port follow the directory. A checked-in literal would make every copy collide
# on both. ctrl/cluster.sh renders it with sed — not envsubst, which is
# gettext-base and absent from a minimal Debian, and rig's whole premise is that
# Docker is the only prerequisite.
#
# Substituted by ctrl/cluster.sh: CLUSTER, NODE_IMAGE, HTTP_PORT, HOST_WORKDIR
# (named without the ${...} braces so this line survives the substitution)
# Node count and audit are READ BACK from this file by lib/config.sh, so this
# YAML is the source of truth for both — there is no second place to update.
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: ${CLUSTER}
# Point containerd at a certs.d directory. registry.sh drops per-host hosts.toml
# files in there afterwards, so switching registry mode never requires
# recreating the cluster.
containerdConfigPatches:
- |-
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
nodes:
- role: control-plane
image: ${NODE_IMAGE}
# One NodePort bridged to the host; an in-cluster gateway owns it. There is
# deliberately no ingress controller — they pin a narrow window of k8s
# versions, and running a trailing-edge control plane is the point.
extraPortMappings:
- containerPort: 30080
hostPort: ${HTTP_PORT}
listenAddress: "0.0.0.0"
protocol: TCP

View File

@@ -0,0 +1,22 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
# The dev overlay is where a rig says how its estate differs from the base —
# which components are real, which are mocked, which point at a live system.
# Kept empty on purpose: the base already boots, and an overlay full of examples
# is harder to read than one that starts blank.
#
# The shape a patch takes, for when the first one is needed:
#
# patches:
# - target: {kind: Service, name: example-service}
# patch: |
# - op: replace
# path: /spec/type
# value: NodePort
# - op: add
# path: /spec/ports/0/nodePort
# value: 30080

148
rig/ctrl/lib/config.sh Normal file
View File

@@ -0,0 +1,148 @@
# 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:
#
# ctrl/versions.env pinned toolchain + image digests (committed)
# ctrl/env.d/<profile> cluster shape (committed)
# ctrl/.env machine-local values and secrets (gitignored)
# the caller's env `make cluster up PROFILE=client` (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.
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"
# 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
[ -f ./.env ] && source ./.env
set +a
# Re-apply overrides now so PROFILE is the caller's before we pick the file.
_config_restore "$saved"
local profile="${PROFILE:-minimal}"
if [ ! -f "./env.d/${profile}.env" ]; then
echo "no such profile: env.d/${profile}.env" >&2
echo "available: $(ls env.d/*.env 2>/dev/null | xargs -n1 basename | sed 's/\.env$//' | tr '\n' ' ')" >&2
exit 1
fi
set -a
source "./env.d/${profile}.env"
[ -f ./.env ] && source ./.env
set +a
_config_restore "$saved"
# 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))}"
# 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.
KIND_CONFIG="${KIND_CONFIG:-kind-config.yaml.tpl}"
KIND_CONFIG_PATH="./k8s/${KIND_CONFIG}"
if [ ! -f "$KIND_CONFIG_PATH" ]; then
echo "no such cluster shape: ctrl/k8s/${KIND_CONFIG}" >&2
echo "available: $(ls k8s/kind-config*.yaml.tpl 2>/dev/null | xargs -n1 basename | tr '\n' ' ')" >&2
exit 1
fi
# Read the shape back out of the YAML rather than trusting a profile to
# restate it. station.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
}
# 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 wizard 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
}

313
rig/ctrl/newbox.sh Executable file
View File

@@ -0,0 +1,313 @@
#!/usr/bin/env bash
# Create a disposable Linux environment to validate the installer from a
# genuinely clean slate — one that can be thrown away without touching the
# environment you actually work in.
#
# This is the ONLY host-aware file in the tree. Everything else needs just a
# Linux with Docker, which is what keeps other host types a later addition
# rather than a rewrite.
#
# On WSL it creates a second distro. There is no .bat and no PowerShell script:
# wsl.exe is callable from inside WSL, and wslpath converts the paths it wants.
# A machine with no WSL at all needs `wsl --install` run once by hand first —
# scripting a reboot-requiring Windows feature install is not worth it.
#
# Docker: borrowed by default, never installed twice
# --------------------------------------------------
# WSL2 distros share one kernel and one network stack, so two dockerd instances
# contend over docker0 and iptables and can disturb the daemon you depend on.
# (That is why Docker Desktop runs one daemon in a dedicated distro and shares
# its socket rather than installing one per distro.)
#
# REUSE_DOCKER=1 (default) borrow the host distro's daemon over /mnt/wsl.
# Nothing is installed; nothing can conflict.
# Requires `ctrl/dockerhost.sh share` once on the
# distro that owns Docker.
# REUSE_DOCKER=0 install a second daemon in the new distro. Only
# if you specifically want to test a from-scratch
# Docker install, and not on a machine you need.
#
# Borrowing is also the more honest test: rig never installs Docker anyway — it
# is the documented prerequisite — so a clean box does not need its own to
# exercise everything rig actually does.
#
# Usage: newbox.sh create | destroy [--purge] | status | shell
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
load_config
REPO="$(cd .. && pwd)"
# The distro is named after this environment, and that derived name is the ONLY
# thing this script will ever destroy. See guard_name().
BOX="${BOX:-${CLUSTER}box}"
BOX_USER="${BOX_USER:-dev}"
# Borrow the host distro's Docker rather than installing a second daemon.
REUSE_DOCKER="${REUSE_DOCKER:-1}"
SHARED_SOCK=/mnt/wsl/shared-docker/docker.sock
WSL_EXE=/mnt/c/Windows/System32/wsl.exe
# ── host detection ─────────────────────────────────────────────────────────
require_wsl() {
if ! grep -qi microsoft /proc/version 2>/dev/null; then
cat >&2 <<'EOF'
newbox is WSL-only for now.
On native Linux you do not need it: rig already isolates environments by
directory (own cluster, context, images and port block), so a second copy in a
second directory is the clean slate. To validate the installer itself against a
bare system, run the wizard against a stock Debian container instead.
EOF
exit 1
fi
if [ ! -x "$WSL_EXE" ]; then
echo "wsl.exe not found at $WSL_EXE" >&2
exit 1
fi
}
wsl_list() { "$WSL_EXE" -l -q 2>/dev/null | tr -d '\0\r'; }
box_exists() { wsl_list | grep -qx "$BOX"; }
# `wsl --unregister` permanently deletes a distro's filesystem. The whole safety
# story is this function: only the name derived from this directory can ever be
# a target, so a typo or a stray argument cannot destroy the distro you work in.
guard_name() {
local derived="${CLUSTER}box"
if [ "$BOX" != "$derived" ]; then
echo "refusing: BOX='$BOX' is not the name derived from this directory ('$derived')." >&2
echo "That guard exists because --unregister is irreversible." >&2
exit 1
fi
if [ -z "$CLUSTER" ] || [ "$BOX" = "box" ]; then
echo "refusing: empty environment name" >&2
exit 1
fi
}
# ── create ─────────────────────────────────────────────────────────────────
rootfs_path() {
local win_home; win_home=$(wslpath "$("$WSL_EXE" -d "$(wsl_list | head -1)" -e printf '%s' "$USERPROFILE" 2>/dev/null || true)" 2>/dev/null || true)
# Simpler and reliable: use the current user's Windows home via /mnt/c.
ls -d /mnt/c/Users/*/ 2>/dev/null | grep -viE '/(All Users|Default|Default User|Public)/$' | head -1
}
build_rootfs() {
local tar="$1"
if [ -f "$tar" ]; then
echo " rootfs cached: $(basename "$tar")"
return
fi
echo " exporting a stock Debian rootfs (cached for next time)"
local cid; cid=$(docker create debian:trixie-slim)
docker export "$cid" > "$tar"
docker rm -f "$cid" >/dev/null
}
provision() {
echo " provisioning (root)"
local hosts_block
hosts_block=$(CLUSTER="$CLUSTER" HTTP_PORT="$HTTP_PORT" \
envsubst < ./hosts.tmpl 2>/dev/null || sed "s/\${CLUSTER}/$CLUSTER/g" ./hosts.tmpl)
# Piped as stdin rather than a second script file, the same shape as any
# remote provisioning heredoc. Everything here is idempotent so a failed run
# can simply be repeated.
"$WSL_EXE" -d "$BOX" -u root -- bash -s <<PROVISION
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq ca-certificates curl gnupg sudo >/dev/null
if [ "$REUSE_DOCKER" = "1" ]; then
# Borrow the host distro's daemon: CLI only, no dockerd, nothing to
# conflict with. The GID must match the owner's or the shared socket is
# unreadable here even though it is visible.
install -m 0755 -d /etc/apt/keyrings
if [ ! -f /etc/apt/keyrings/docker.asc ]; then
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
fi
echo "deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \$(. /etc/os-release && echo \$VERSION_CODENAME) stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y -qq docker-ce-cli >/dev/null
echo "export DOCKER_HOST=unix://$SHARED_SOCK" > /etc/profile.d/rig-docker-host.sh
if [ -f /mnt/wsl/shared-docker/OWNER ]; then
gid=\$(awk '/docker gid:/ {print \$3}' /mnt/wsl/shared-docker/OWNER)
if [ -n "\$gid" ]; then
getent group docker >/dev/null && groupmod -g "\$gid" docker || groupadd -g "\$gid" docker
fi
fi
else
# A second daemon. Only when deliberately testing a from-scratch install.
install -m 0755 -d /etc/apt/keyrings
if [ ! -f /etc/apt/keyrings/docker.asc ]; then
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
fi
echo "deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \$(. /etc/os-release && echo \$VERSION_CODENAME) stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y -qq docker-ce docker-ce-cli containerd.io >/dev/null
fi
id -u "$BOX_USER" >/dev/null 2>&1 || useradd -m -s /bin/bash "$BOX_USER"
usermod -aG sudo,docker "$BOX_USER"
echo "$BOX_USER ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/90-$BOX_USER
chmod 0440 /etc/sudoers.d/90-$BOX_USER
# systemd is off by default in WSL, and Docker needs it. Takes effect on the
# next start of this distro, which is why create() terminates it below.
cat > /etc/wsl.conf <<WSLCONF
[boot]
systemd=true
[user]
default=$BOX_USER
WSLCONF
# The default inotify limits are low enough that file watching silently stops
# working — no error, changes just stop being noticed. Fix it before it bites.
cat > /etc/sysctl.d/99-rig.conf <<SYSCTL
fs.inotify.max_user_watches=524288
fs.inotify.max_user_instances=512
SYSCTL
if ! grep -q 'rig environment' /etc/hosts 2>/dev/null; then
{ echo ""; echo "# rig environment"; cat <<'HOSTS'
$hosts_block
HOSTS
} >> /etc/hosts
fi
touch /etc/rig-provisioned
PROVISION
}
create() {
require_wsl
guard_name
local winhome; winhome=$(rootfs_path)
[ -n "$winhome" ] || { echo "could not locate the Windows user directory" >&2; exit 1; }
local tar="${winhome}rig-rootfs.tar"
local installdir="${winhome}WSL/${BOX}"
echo "creating '$BOX'"
if [ "$REUSE_DOCKER" = "1" ]; then
echo " docker: borrowing the host distro's daemon (nothing installed)"
if [ ! -S "$SHARED_SOCK" ]; then
echo
echo " No shared socket yet. In the distro that owns Docker, run once:"
echo " sudo bash ctrl/dockerhost.sh share"
echo " That adds one systemd drop-in and nothing else; undo with 'unshare'."
echo " Continuing — the box will be created, but Docker won't work in it"
echo " until you do that."
fi
else
echo
echo " REUSE_DOCKER=0: installing a SECOND Docker daemon."
echo " WSL distros share a network stack, so this can disturb Docker in"
echo " the distro you work in. Ctrl-C now if that is a bad trade today."
echo
sleep 4
fi
echo
if box_exists; then
echo " distro already registered"
else
build_rootfs "$tar"
mkdir -p "$installdir"
"$WSL_EXE" --import "$BOX" "$(wslpath -w "$installdir")" "$(wslpath -w "$tar")" --version 2
fi
# Resumable: a partially-created box is finished rather than restarted.
if "$WSL_EXE" -d "$BOX" -u root -- test -f /etc/rig-provisioned 2>/dev/null; then
echo " already provisioned"
else
provision
echo " restarting the distro so systemd and group membership apply"
"$WSL_EXE" --terminate "$BOX" # ONLY this distro; never --shutdown
fi
echo " copying rig in"
tar c -C "$REPO" --exclude=def --exclude=.git --exclude=ctrl/.env . \
| "$WSL_EXE" -d "$BOX" -u "$BOX_USER" -- bash -lc "mkdir -p ~/rig && tar x -C ~/rig"
echo
echo " docker: $("$WSL_EXE" -d "$BOX" -u "$BOX_USER" -- bash -lc 'systemctl is-active docker 2>/dev/null || echo inactive')"
echo
echo "next:"
echo " make newbox shell # a shell inside it"
echo " then: cd ~/rig && make station && make deps && make cluster up"
echo
echo "For a browser on Windows to resolve the hostnames, paste this into"
echo "C:\\Windows\\System32\\drivers\\etc\\hosts (it has no wildcard support):"
CLUSTER="$CLUSTER" envsubst < ./hosts.tmpl 2>/dev/null | grep -v '^#' | grep -v '^$' | sed 's/^/ /'
}
# ── the rest ───────────────────────────────────────────────────────────────
destroy() {
require_wsl
guard_name
if ! box_exists; then
echo "no distro '$BOX' to remove"
else
echo "about to PERMANENTLY delete the distro '$BOX' and its filesystem."
"$WSL_EXE" --terminate "$BOX" 2>/dev/null || true
"$WSL_EXE" --unregister "$BOX"
echo " unregistered"
fi
local winhome; winhome=$(rootfs_path)
rm -rf "${winhome}WSL/${BOX}" 2>/dev/null || true
if [ "${1:-}" = "--purge" ]; then
rm -f "${winhome}rig-rootfs.tar"
echo " cached rootfs removed"
fi
}
status() {
require_wsl
echo "environment $CLUSTER"
echo "distro $BOX"
if box_exists; then
echo "registered yes"
echo "provisioned $("$WSL_EXE" -d "$BOX" -u root -- test -f /etc/rig-provisioned 2>/dev/null && echo yes || echo no)"
echo "docker $("$WSL_EXE" -d "$BOX" -u root -- bash -lc 'systemctl is-active docker 2>/dev/null' || echo unknown)"
echo "rig copied $("$WSL_EXE" -d "$BOX" -u "$BOX_USER" -- bash -lc 'test -f ~/rig/Makefile && echo yes || echo no' 2>/dev/null)"
else
echo "registered no"
fi
echo
echo "all distros (this one is never touched unless it is '$BOX'):"
wsl_list | sed 's/^/ /'
}
shell() {
require_wsl
box_exists || { echo "no distro '$BOX' — run 'make newbox' first" >&2; exit 1; }
"$WSL_EXE" -d "$BOX" -u "$BOX_USER" --cd '~'
}
case "${1:-status}" in
create) create ;;
destroy) shift; destroy "${1:-}" ;;
status) status ;;
shell) shell ;;
*) echo "usage: $0 [create|destroy [--purge]|status|shell]" >&2; exit 1 ;;
esac

103
rig/ctrl/ports.sh Executable file
View File

@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Give each environment its own block of host ports.
#
# New versions of a system mean new clusters on ONE machine, not new machines.
# Cluster name, kubectl context, registry container and image tag already derive
# from the directory name, so two copies never collide there — but host ports are
# a single shared namespace and would.
#
# The block is derived from the directory name: stateless, stable, and requiring
# no coordination between copies that know nothing about each other.
#
# base = 20000 + (hash(slug) % 200) * 10
# +0 HTTP +1 HTTPS +2 TILT +3 REGISTRY (+4..9 reserved)
#
# 20000+ deliberately avoids the ports something is already likely to hold: 80,
# 443, 3000, 5432, 8000, 8080.
#
# Derivation is a default, not a decision. On first use the resolved block is
# 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
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
# derive_port_base lives in lib/config.sh so every script resolves the same block
# without going through this one.
derive_base() { derive_port_base "$1"; }
derive() {
load_config
local base; base=$(derive_base "$CLUSTER")
DERIVED_HTTP=$base
DERIVED_HTTPS=$((base + 1))
DERIVED_TILT=$((base + 2))
DERIVED_REGISTRY=$((base + 3))
}
show() {
derive
echo "environment $CLUSTER"
echo "derived base $(derive_base "$CLUSTER")"
echo
printf " %-14s %-8s %-8s %s\n" KEY DERIVED ACTIVE SOURCE
_row HTTP_PORT "$DERIVED_HTTP"
_row HTTPS_PORT "$DERIVED_HTTPS"
_row TILT_PORT "$DERIVED_TILT"
_row REGISTRY_PORT "$DERIVED_REGISTRY"
}
_row() {
local key="$1" derived="$2" active="${!1:-}" src="derived"
if [ -n "$active" ] && [ "$active" != "$derived" ]; then
src="override"
elif [ -z "$active" ]; then
active="$derived"
fi
printf " %-14s %-8s %-8s %s\n" "$key" "$derived" "$active" "$src"
}
# Write the derived block into ctrl/.env, once. Existing keys are never
# rewritten — an override stays an override.
persist() {
derive
[ -f ./.env ] || cp ./.env.example ./.env
local wrote=0 key val
for key in HTTP_PORT:$DERIVED_HTTP \
HTTPS_PORT:$DERIVED_HTTPS \
TILT_PORT:$DERIVED_TILT \
REGISTRY_PORT:$DERIVED_REGISTRY; do
val="${key#*:}"; key="${key%%:*}"
if grep -qE "^${key}=[0-9]" ./.env 2>/dev/null; then
continue
fi
if [ "$wrote" -eq 0 ]; then
{
echo ""
echo "# Port block for this environment, derived from the directory name"
echo "# so copies never collide. Pinned here on first use — edit freely."
} >> ./.env
wrote=1
fi
# Replace a commented/empty placeholder if present, else append.
if grep -qE "^#?\s*${key}=" ./.env 2>/dev/null; then
sed -i "s|^#\?\s*${key}=.*|${key}=${val}|" ./.env
else
echo "${key}=${val}" >> ./.env
fi
done
[ "$wrote" -eq 1 ] && echo "pinned port block into ctrl/.env" || echo "ports already set in ctrl/.env"
return 0
}
case "${1:-show}" in
show) show ;;
derive) derive; echo "$DERIVED_HTTP $DERIVED_HTTPS $DERIVED_TILT $DERIVED_REGISTRY" ;;
persist) persist ;;
*) echo "usage: $0 [show|derive|persist]" >&2; exit 1 ;;
esac

216
rig/ctrl/registry.sh Executable file
View File

@@ -0,0 +1,216 @@
#!/usr/bin/env bash
# Registry plumbing. THIS is the seam — not a tool.
#
# Four modes, selected by REGISTRY_MODE in the active profile:
#
# none Tilt builds straight into the node. No registry at all — and so no
# guard against an outward push: an unqualified image name means
# docker.io/library/<name>, and only Tilt's kind detection stands
# between that and a real push. Throwaway use only; every profile
# here now defaults to `local` instead.
# local a registry:2 container wired into the cluster.
# mirror the same container, but configured as a pull-through CACHE of the
# corporate registry. What a locked-down client actually looks like:
# images originate from corp, you don't hammer it, and you keep
# working when the VPN drops.
# remote no local container; pull straight from the corporate registry using
# an imagePullSecret.
#
# Deliberately a script rather than a tool. ctlptl collapses the `local` wiring
# into one line, but its Registry spec only accepts name/port/image/listenAddress
# — there is no way to set REGISTRY_PROXY_REMOTEURL, so it cannot express
# `mirror` at all. Keeping the seam here is what keeps the corporate registry
# swappable.
#
# Usage: registry.sh up | down | status
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
load_config
REG_NAME="${CLUSTER}-registry"
REG_PORT="${REGISTRY_PORT:-5005}"
K="kubectl --context ${KUBECONTEXT}"
# ── CA trust ───────────────────────────────────────────────────────────────
# A corporate registry is almost always fronted by an internal CA, and trust has
# to reach three separate places. Nothing does this for you, and the symptom when
# it's missing is an opaque:
# x509: certificate signed by unknown authority
#
# 1. the host docker daemon — /etc/docker/certs.d/<host>/ca.crt (needs root)
# 2. every kind node's containerd — nodes do NOT inherit host trust
# 3. anything doing HTTPS from inside the cluster, in its own trust store
#
# We handle (2) here because it's ours to handle. (1) is reported by station.sh
# since it needs root. (3) belongs to the workload.
install_ca_into_nodes() {
[ -n "${REGISTRY_CA_FILE:-}" ] || return 0
if [ ! -r "$REGISTRY_CA_FILE" ]; then
echo "REGISTRY_CA_FILE is set but not readable: $REGISTRY_CA_FILE" >&2
exit 1
fi
echo " distributing CA to kind nodes"
local node
for node in $(kind get nodes --name "$CLUSTER"); do
docker cp "$REGISTRY_CA_FILE" "$node:/usr/local/share/ca-certificates/corp-registry.crt"
docker exec "$node" update-ca-certificates >/dev/null 2>&1
docker exec "$node" systemctl restart containerd
done
}
# Point containerd at a registry host. The cluster config already set
# config_path=/etc/containerd/certs.d, so this is a per-node drop-in and needs no
# cluster recreate — which is what lets registry mode change on a live cluster.
write_hosts_toml() {
local host="$1" upstream="$2" skip_verify="${3:-false}"
local node
for node in $(kind get nodes --name "$CLUSTER"); do
docker exec "$node" mkdir -p "/etc/containerd/certs.d/${host}"
docker exec -i "$node" cp /dev/stdin "/etc/containerd/certs.d/${host}/hosts.toml" <<TOML
server = "${upstream}"
[host."${upstream}"]
capabilities = ["pull", "resolve"]
skip_verify = ${skip_verify}
TOML
done
}
# ── the local container (local + mirror) ───────────────────────────────────
start_registry_container() {
if [ "$(docker inspect -f '{{.State.Running}}' "$REG_NAME" 2>/dev/null || true)" = "true" ]; then
echo " registry container '$REG_NAME' already running"
return
fi
docker rm -f "$REG_NAME" >/dev/null 2>&1 || true
local args=(-d --restart=always --name "$REG_NAME"
-p "127.0.0.1:${REG_PORT}:5000")
if [ "$REGISTRY_MODE" = "mirror" ]; then
if [ -z "${REGISTRY_REMOTE_URL:-}" ]; then
echo "REGISTRY_MODE=mirror needs REGISTRY_REMOTE_URL in ctrl/.env" >&2
exit 1
fi
echo " starting pull-through cache of ${REGISTRY_REMOTE_URL}"
args+=(-e "REGISTRY_PROXY_REMOTEURL=${REGISTRY_REMOTE_URL}")
[ -n "${REGISTRY_USER:-}" ] && args+=(-e "REGISTRY_PROXY_USERNAME=${REGISTRY_USER}")
[ -n "${REGISTRY_PASSWORD:-}" ] && args+=(-e "REGISTRY_PROXY_PASSWORD=${REGISTRY_PASSWORD}")
if [ -n "${REGISTRY_CA_FILE:-}" ]; then
args+=(-v "$(readlink -f "$REGISTRY_CA_FILE"):/etc/ssl/certs/corp-ca.crt:ro")
fi
else
echo " starting local registry"
fi
docker run "${args[@]}" "$REGISTRY_IMAGE" >/dev/null
}
# The registry must share a network with the nodes so they can resolve it by
# container name; localhost inside a node is the node, not the host.
join_kind_network() {
if docker inspect -f '{{json .NetworkSettings.Networks}}' "$REG_NAME" | grep -q '"kind"'; then
return
fi
docker network connect kind "$REG_NAME" >/dev/null 2>&1 || true
}
# The documented contract that tells tooling (Tilt, skaffold) where the local
# registry is, so they don't have to be configured separately.
apply_hosting_configmap() {
$K apply -f - <<YAML >/dev/null
apiVersion: v1
kind: ConfigMap
metadata:
name: local-registry-hosting
namespace: kube-public
data:
localRegistryHosting.v1: |
host: "localhost:${REG_PORT}"
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
YAML
}
# ── modes ──────────────────────────────────────────────────────────────────
up() {
echo "registry: ${REGISTRY_MODE}"
case "$REGISTRY_MODE" in
none)
echo " no registry — images are built straight into the node"
;;
local|mirror)
start_registry_container
join_kind_network
install_ca_into_nodes
# Nodes reach the registry by container name on the shared network;
# the host reaches it on localhost:PORT. Both names must resolve.
write_hosts_toml "localhost:${REG_PORT}" "http://${REG_NAME}:5000"
if [ "$REGISTRY_MODE" = "mirror" ]; then
# Anything asking for docker.io transparently goes to the cache.
write_hosts_toml "docker.io" "http://${REG_NAME}:5000"
fi
apply_hosting_configmap
echo " ready at localhost:${REG_PORT}"
;;
remote)
if [ -z "${REGISTRY_REMOTE_URL:-}" ]; then
echo "REGISTRY_MODE=remote needs REGISTRY_REMOTE_URL in ctrl/.env" >&2
exit 1
fi
install_ca_into_nodes
local host="${REGISTRY_REMOTE_URL#*://}"; host="${host%%/*}"
if [ -n "${REGISTRY_USER:-}" ]; then
echo " creating imagePullSecret for ${host}"
$K create secret docker-registry regcred \
--docker-server="$host" \
--docker-username="$REGISTRY_USER" \
--docker-password="$REGISTRY_PASSWORD" \
--dry-run=client -o yaml | $K apply -f - >/dev/null
# Attach to the default ServiceAccount so plain pods inherit it.
$K patch serviceaccount default \
-p '{"imagePullSecrets":[{"name":"regcred"}]}' >/dev/null
fi
echo " pulling directly from ${host}"
;;
*)
echo "unknown REGISTRY_MODE '$REGISTRY_MODE' (expected none|local|mirror|remote)" >&2
exit 1
;;
esac
}
down() {
if docker inspect "$REG_NAME" >/dev/null 2>&1; then
echo "removing registry container '$REG_NAME'"
docker rm -f "$REG_NAME" >/dev/null
fi
}
status() {
echo "mode ${REGISTRY_MODE}"
if docker inspect "$REG_NAME" >/dev/null 2>&1; then
echo "container ${REG_NAME} $(docker inspect -f '{{.State.Status}}' "$REG_NAME")"
echo "endpoint localhost:${REG_PORT}"
else
echo "container none"
fi
[ -n "${REGISTRY_REMOTE_URL:-}" ] && echo "upstream ${REGISTRY_REMOTE_URL}"
[ -n "${REGISTRY_CA_FILE:-}" ] && echo "ca ${REGISTRY_CA_FILE}"
return 0
}
case "${1:-status}" in
up) up ;;
down) down ;;
status) status ;;
*) echo "usage: $0 [up|down|status]" >&2; exit 1 ;;
esac

249
rig/ctrl/setup.sh Executable file
View File

@@ -0,0 +1,249 @@
#!/usr/bin/env bash
# Prepare a machine to run rig, and say plainly what worked, what was already
# done, and what is left for a human.
#
# This is the grouped entry point: `make setup`. Every step is idempotent and
# independently checked, so running it twice is safe and running it on a
# half-configured machine finishes the job rather than starting over.
#
# It deliberately does NOT abort on the first failure. A setup script that dies
# at step 2 hides the fact that steps 4 and 5 were also going to fail — and on
# an unfamiliar machine, the full picture is the whole point. Failures are
# collected and reported together, and the exit code reflects the worst outcome.
#
# The same script runs inside a fresh throwaway distro (newbox), so the
# provisioning path and the everyday path cannot drift apart.
#
# Usage:
# setup.sh # host checks + the dev toolchain
# setup.sh core # kubectl and jq only — no cluster tooling
# setup.sh --share-docker # ...and offer this distro's Docker to others
# setup.sh --cluster # ...and bring the cluster up
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
load_config
WITH_SHARE=0
WITH_CLUSTER=0
# Cluster tooling is not wanted everywhere: a managed or corporate-issued
# machine may legitimately want kubectl and nothing that builds clusters.
TIER=dev
for a in "$@"; do
case "$a" in
core|dev) TIER="$a" ;;
--share-docker) WITH_SHARE=1 ;;
--cluster) WITH_CLUSTER=1 ;;
*) echo "unknown option: $a" >&2; exit 1 ;;
esac
done
if [ "$TIER" = "core" ] && [ "$WITH_CLUSTER" -eq 1 ]; then
echo "core tier installs no cluster tooling, so --cluster cannot work" >&2
exit 1
fi
# ── step framework ─────────────────────────────────────────────────────────
# Statuses are deliberately distinct: "already" and "done" both mean success but
# tell you very different things about the machine you are on.
STEP_NAMES=()
STEP_STATUS=()
STEP_NOTE=()
WORST=0
record() {
STEP_NAMES+=("$1"); STEP_STATUS+=("$2"); STEP_NOTE+=("${3:-}")
# Only a genuine failure is a non-zero exit. "manual" means the machine is
# fine and you have something to do — reporting that as an error makes the
# whole run look broken and trains people to ignore the output.
[ "$2" = "fail" ] && WORST=1 || true
local mark
case "$2" in
already) mark=" ok " ;;
done) mark=" done " ;;
skip) mark=" skip " ;;
manual) mark="MANUAL" ;;
fail) mark=" FAIL " ;;
esac
printf "[%s] %-22s %s\n" "$mark" "$1" "${3:-}"
}
# ── steps ──────────────────────────────────────────────────────────────────
step_host() {
local out
if ! out=$(bash ./wizard.sh detect 2>&1); then
record host fail "detection failed"
return
fi
# Anything the wizard flagged with '!' needs a human; surface the count here
# and the detail below rather than burying it.
local warns; warns=$(echo "$out" | grep -c '^\s*!' || true)
HOST_DETAIL="$out"
if [ "$warns" -gt 0 ]; then
record host manual "$warns item(s) need attention — see below"
else
record host already "no problems detected"
fi
}
step_toolchain() {
local want="kubectl jq"
[ "$TIER" = "dev" ] && want="$want kind tilt"
local missing=""
for b in $want; do
command -v "$b" >/dev/null 2>&1 || missing="$missing $b"
done
if [ -z "$missing" ]; then
record toolchain already "$TIER: $want"
return
fi
if bash ./wizard.sh install "$TIER" >/tmp/rig-deps.$$ 2>&1; then
local still=""
for b in $want; do
[ -x "${OUT_BIN:-$HOME/.local/bin}/$b" ] || still="$still $b"
done
if [ -n "$still" ]; then
record toolchain fail "still missing:$still (see /tmp/rig-deps.$$)"
else
record toolchain done "$TIER, installed:$missing"
rm -f "/tmp/rig-deps.$$"
fi
else
record toolchain fail "install failed — see /tmp/rig-deps.$$"
fi
}
step_path() {
local bin="${OUT_BIN:-$HOME/.local/bin}"
case ":$PATH:" in
*":$bin:"*) ;;
*) record path manual "add to ~/.bashrc: export PATH=\"$bin:\$PATH\""; return ;;
esac
if grep -qs "$bin" "$HOME/.bashrc" "$HOME/.profile" 2>/dev/null; then
record path already "$bin on PATH and persisted"
else
record path manual "on PATH now, but not persisted in ~/.bashrc"
fi
}
step_docker() {
if ! command -v docker >/dev/null 2>&1; then
record docker fail "no docker cli — this is the one prerequisite rig cannot install"
return
fi
if docker info >/dev/null 2>&1; then
record docker already "$(docker version --format '{{.Server.Version}}' 2>/dev/null)"
else
record docker fail "daemon unreachable (in the docker group? logged out and back in?)"
fi
}
step_share_docker() {
if [ "$WITH_SHARE" -ne 1 ]; then
record docker-share skip "not requested (--share-docker)"
return
fi
if ! grep -qi microsoft /proc/version 2>/dev/null; then
record docker-share skip "not WSL — sharing only applies between WSL distros"
return
fi
if [ -f /etc/systemd/system/docker.service.d/10-rig-shared-socket.conf ]; then
record docker-share already "this distro is offering its Docker to others"
return
fi
# Needs root, and asking mid-script is worse than telling the user the
# single command to run.
if [ "$(id -u)" -ne 0 ] && ! sudo -n true 2>/dev/null; then
record docker-share manual "run: sudo bash ctrl/dockerhost.sh share"
return
fi
if sudo bash ./dockerhost.sh share >/tmp/rig-share.$$ 2>&1; then
record docker-share done "this distro now owns the shared Docker"
rm -f "/tmp/rig-share.$$"
else
record docker-share fail "see /tmp/rig-share.$$"
fi
}
step_ports() {
local busy=""
for entry in "HTTP:$HTTP_PORT" "HTTPS:$HTTPS_PORT" "TILT:$TILT_PORT" "REGISTRY:$REGISTRY_PORT"; do
local p="${entry#*:}"
if command -v ss >/dev/null 2>&1 && ss -ltn "sport = :$p" 2>/dev/null | grep -q LISTEN; then
busy="$busy ${entry%%:*}($p)"
fi
done
if [ -n "$busy" ]; then
record ports fail "in use:$busy — override in ctrl/.env or rename the directory"
else
record ports already "$HTTP_PORT-$REGISTRY_PORT free"
fi
}
step_cluster() {
if [ "$TIER" = "core" ]; then
record cluster skip "core tier — no cluster tooling on this machine"
return
fi
if [ "$WITH_CLUSTER" -ne 1 ]; then
record cluster skip "not requested (--cluster)"
return
fi
if kind get clusters 2>/dev/null | grep -qx "$CLUSTER"; then
record cluster already "'$CLUSTER' exists"
return
fi
if bash ./cluster.sh up >/tmp/rig-cluster.$$ 2>&1; then
record cluster done "'$CLUSTER' created"
rm -f "/tmp/rig-cluster.$$"
else
record cluster fail "see /tmp/rig-cluster.$$"
fi
}
# ── run ────────────────────────────────────────────────────────────────────
echo "setting up '$CLUSTER'"
echo
HOST_DETAIL=""
step_host
step_toolchain
step_path
step_docker
step_share_docker
step_ports
step_cluster
echo
if [ -n "$HOST_DETAIL" ]; then
echo "host detail"
echo "$HOST_DETAIL" | sed 's/^/ /'
echo
fi
# Repeat only what still needs action, so the tail of the output is a to-do list
# rather than a transcript.
outstanding=0
for i in "${!STEP_NAMES[@]}"; do
case "${STEP_STATUS[$i]}" in
fail|manual)
[ "$outstanding" -eq 0 ] && echo "outstanding:"
outstanding=1
printf " %-8s %-16s %s\n" "${STEP_STATUS[$i]}" "${STEP_NAMES[$i]}" "${STEP_NOTE[$i]}"
;;
esac
done
if [ "$outstanding" -eq 0 ]; then
echo "ready. next: make cluster up && make docs"
else
echo
echo "(nothing was aborted — every step ran so the list above is complete)"
fi
exit "$WORST"

106
rig/ctrl/station.sh Executable file
View File

@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Station check: is this workstation ready to run rig?
#
# Reports and instructs; never silently fixes anything. Everything it finds is
# either already fine, or something a human has to decide on.
#
# Runs the wizard's host detection in a container when Docker is the only thing
# installed, or directly when the toolchain is already present. Then adds the
# checks that need this repo's config: profile sanity, CA trust, port clashes.
set -euo pipefail
cd "$(dirname "$0")"
WIZARD_IMAGE="${WIZARD_IMAGE:-$(basename "$(cd .. && pwd)")-wizard}"
# Host detection. Prefer running it bare — it needs no dependencies beyond
# coreutils — and fall back to the container only if this shell can't.
bash ./wizard.sh detect
# ── repo-level checks ──────────────────────────────────────────────────────
source ./lib/config.sh
load_config
echo
echo "config"
echo " profile ${PROFILE_NAME} (nodes=${NODES} audit=${AUDIT})"
echo " cluster ${CLUSTER} (context ${KUBECONTEXT})"
echo " registry ${REGISTRY_MODE}"
echo " ingress ${INGRESS_MODE}"
if [ ! -f ./.env ]; then
echo " ! ctrl/.env missing — copy it: cp ctrl/.env.example ctrl/.env"
fi
# A 3-node profile on a box that's already full is the most common first
# failure, and it presents as pods stuck Pending rather than anything obvious.
avail=$(awk '/^MemAvailable:/{printf "%d", $2/1024/1024}' /proc/meminfo)
need=$((NODES * 2))
if [ "$avail" -lt "$need" ]; then
echo " ! profile '${PROFILE_NAME}' wants ~${need} GB, ${avail} GB available"
echo " 'make cluster list' shows what else is running; 'make cluster free' stops it"
fi
# The CA reaches three places and only one of them is ours. Report the other two.
if [ -n "${REGISTRY_CA_FILE:-}" ]; then
echo
echo "registry CA"
if [ ! -r "$REGISTRY_CA_FILE" ]; then
echo " ! REGISTRY_CA_FILE not readable: $REGISTRY_CA_FILE"
else
echo " file $REGISTRY_CA_FILE"
host="${REGISTRY_REMOTE_URL#*://}"; host="${host%%/*}"
if [ -n "$host" ] && [ ! -f "/etc/docker/certs.d/${host}/ca.crt" ]; then
echo " ! the HOST docker daemon does not trust it yet:"
echo " sudo mkdir -p /etc/docker/certs.d/${host}"
echo " sudo cp ${REGISTRY_CA_FILE} /etc/docker/certs.d/${host}/ca.crt"
echo " (kind nodes are handled by registry.sh; in-cluster clients are the workload's job)"
fi
fi
fi
# Host ports this environment will try to bind. Checked before cluster creation
# because docker reports a clash halfway through, as an opaque
# "failed to bind host port ...: address already in use".
echo
echo "ports (block derived from the directory name — see 'make ports')"
port_busy() {
if command -v ss >/dev/null 2>&1; then
ss -ltn "sport = :$1" 2>/dev/null | grep -q LISTEN && return 0 || return 1
fi
# iproute2 is absent from a minimal Debian, so fall back to procfs rather
# than silently reporting everything as free.
local hex; hex=$(printf ':%04X' "$1")
grep -qi "^ *[0-9]*: [0-9A-F]*$hex " /proc/net/tcp /proc/net/tcp6 2>/dev/null
}
# A port held by THIS environment's own cluster is not a clash — it is the thing
# working. Reporting it as a problem every time the cluster is up would train
# people to ignore this section, which is the opposite of the point.
# Extract with a second grep rather than `tr -d ':->'`: in tr, ':->' is the
# character RANGE ':' to '>', which does not contain '-', so the trailing dash
# survives and nothing ever matches.
ours=$(docker ps --filter "label=io.x-k8s.kind.cluster=${CLUSTER}" \
--format '{{.Ports}}' 2>/dev/null | tr ',' '\n' \
| grep -oE ':[0-9]+->' | grep -oE '[0-9]+' || true)
clash=0
for entry in "HTTP:${HTTP_PORT}" "HTTPS:${HTTPS_PORT}" \
"TILT:${TILT_PORT}" "REGISTRY:${REGISTRY_PORT}"; do
name="${entry%%:*}"; p="${entry#*:}"
[ -n "$p" ] || continue
if ! port_busy "$p"; then
printf " %-9s %-6s free\n" "$name" "$p"
elif echo "$ours" | grep -qx "$p"; then
printf " %-9s %-6s in use by this environment's cluster\n" "$name" "$p"
else
printf " ! %-9s %-6s IN USE by something else\n" "$name" "$p"
clash=1
fi
done
if [ "$clash" -eq 1 ]; then
echo " override the clashing one in ctrl/.env, e.g. HTTP_PORT=21080"
echo " (or rename this directory — the whole block follows the name)"
fi

54
rig/ctrl/versions.env Normal file
View File

@@ -0,0 +1,54 @@
# Pinned toolchain — the single manifest the wizard installs from.
# Every entry is a single binary; none of them needs an apt repo.
# kubectl fully static
# kind libc only
# tilt libc + libstdc++ + libgcc (present in base Debian)
# jq upstream static build (Debian's is linked against libjq/libonig)
#
# Checksums are the upstream-published SHA256 of the linux/amd64 artifact.
# To bump: change the version, then re-run `bash ctrl/versions-refresh.sh` and
# commit the result — never hand-edit a checksum.
KIND_VERSION=v0.32.0
KIND_SHA256=50030de23cf40a18505f20426f6a8506bedf13c6e509244bd1fa9463721b0f54
KIND_URL=https://github.com/kubernetes-sigs/kind/releases/download/${KIND_VERSION}/kind-linux-amd64
KUBECTL_VERSION=v1.36.3
KUBECTL_SHA256=ebbd080e7c2e275093b55915722043257eb24004363e20acb3c4d71919f88336
KUBECTL_URL=https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl
TILT_VERSION=0.37.6
TILT_SHA256=e9672b8a18d43501f35dcfe98465969a7db0e436b36cf0c50c7e6f8d40de5fe6
TILT_URL=https://github.com/tilt-dev/tilt/releases/download/v${TILT_VERSION}/tilt.${TILT_VERSION}.linux.x86_64.tar.gz
JQ_VERSION=1.8.2
JQ_SHA256=b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f
JQ_URL=https://github.com/jqlang/jq/releases/download/jq-${JQ_VERSION}/jq-linux-amd64
# Node images shipped with KIND_VERSION above, pinned by digest so a kind upgrade
# can never silently move the k8s version. Profiles select one via K8S_VERSION.
# Older entries are kept deliberately: running a trailing-edge control plane is
# part of simulating a legacy estate.
NODE_IMAGE_v1_36=kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5
NODE_IMAGE_v1_35=kindest/node:v1.35.5@sha256:ce977ae6d65918d0b58a5f8b5e940429c2ce42fa3a5619ec2bbc60b949c0ac95
NODE_IMAGE_v1_34=kindest/node:v1.34.8@sha256:02722c2dedddcfc00febf5d27fbeb9b7b2c14294c82109ff4a85d89ac9ba3256
NODE_IMAGE_v1_33=kindest/node:v1.33.12@sha256:3f5c8443c620245e4d355cfe09e96a91ead32ceaa569d3f1ca9edf0cb2fe2ff4
# Images pulled at runtime (registry, mocks). Pinned by tag; the registry mode
# decides where they are pulled FROM.
REGISTRY_IMAGE=registry:2
STUB_IMAGE=python:3.12-slim
# Addons, installed by ctrl/addons/<name>.sh when listed in a profile's ADDONS.
CERT_MANAGER_VERSION=v1.21.1
METRICS_SERVER_VERSION=v0.9.0
METALLB_VERSION=v0.16.0
# Dependency containers. These mirror soleprint's cabinets
# (soleprint/station/cabinets/), so a room that declares postgres gets the same
# thing whether it runs on compose or in the cluster. Pinned by tag rather than
# digest because they are ordinary upstream images with no supply chain claim
# attached — bump freely, and preload them for the offline profile.
POSTGRES_IMAGE=postgres:16-alpine
REDIS_IMAGE=redis:7-alpine
AIRFLOW_IMAGE=apache/airflow:2.10.4

381
rig/ctrl/wizard.sh Executable file
View File

@@ -0,0 +1,381 @@
#!/usr/bin/env bash
# The installation wizard: detect the host, install a pinned toolchain onto it,
# then report what it could not do. It never runs the cluster and never mutates
# the host outside the directories mounted into it.
#
# Usage (normally via `make station` / `make deps`, or directly):
# wizard.sh detect # report host facts only, change nothing
# wizard.sh fetch [core|dev] [--to DIR] # download + verify into DIR
# wizard.sh install [core|dev] # detect, fetch, install, report
#
# Tiers: 'core' is kubectl + jq (talk to a cluster); 'dev' adds kind and tilt
# Default is dev.
#
# Runs both inside the wizard container and bare on a host. Inside the
# container, host files are read through $HOST_ROOT (mount / as :ro); bare, it
# falls back to /.
set -euo pipefail
# Keep the caller's cwd so a relative --to resolves where the user expects,
# not against ctrl/ once we've moved.
INVOKED_FROM="$PWD"
cd "$(dirname "$0")"
source ./versions.env
# Resolve a possibly-relative path against the caller's original directory.
abspath() {
case "$1" in
/*) echo "$1" ;;
*) echo "$INVOKED_FROM/$1" ;;
esac
}
OUT_BIN="${OUT_BIN:-$HOME/.local/bin}"
HOST_ROOT="${HOST_ROOT:-/}"
DEPS_SOURCE="${DEPS_SOURCE:-upstream}"
DEPS_ARTIFACTORY_URL="${DEPS_ARTIFACTORY_URL:-}"
BAKED_BIN="${BAKED_BIN:-/opt/rig/bin}"
# Collected by detect(), printed by report_manual() at the very end.
MANUAL=()
# Host FILES (/etc/..., /mnt/c/...) must be read through the mount. Kernel-level
# facts (kernel version, meminfo, inotify) are shared with the container, so the
# container's own view is already the host's.
host_file() {
local p="${1#/}"
if [ "$HOST_ROOT" != "/" ] && [ -e "$HOST_ROOT/$p" ]; then
echo "$HOST_ROOT/$p"
else
echo "/$p"
fi
}
# ── detect ─────────────────────────────────────────────────────────────────
is_wsl() { grep -qi microsoft /proc/version 2>/dev/null; }
detect() {
echo "host"
echo " kernel $(uname -r)"
local osr; osr=$(host_file /etc/os-release)
[ -r "$osr" ] && echo " distro $(sed -n 's/^PRETTY_NAME="\(.*\)"/\1/p' "$osr")"
local total_kb avail_kb
total_kb=$(awk '/^MemTotal:/{print $2}' /proc/meminfo)
avail_kb=$(awk '/^MemAvailable:/{print $2}' /proc/meminfo)
printf " memory %d GB total, %d GB available\n" \
$((total_kb / 1024 / 1024)) $((avail_kb / 1024 / 1024))
if [ $((avail_kb / 1024 / 1024)) -lt 4 ]; then
echo " ! under 4 GB available — a multi-node profile will struggle."
echo " 'make cluster list' shows the others; 'make cluster free' stops them."
fi
detect_wsl
detect_docker
detect_inotify
}
detect_wsl() {
if ! is_wsl; then
echo " platform native linux"
return
fi
echo " platform WSL"
# systemd is off by default in WSL, and the ingress/DNS paths that use a
# host service need it. Enabling it requires a Windows-side restart, which
# cannot be issued from inside the distro.
local wc; wc=$(host_file /etc/wsl.conf)
if [ -r "$wc" ] && grep -qE '^\s*systemd\s*=\s*true' "$wc"; then
echo " systemd enabled in wsl.conf"
else
echo " ! systemd not enabled in /etc/wsl.conf"
MANUAL+=("Enable systemd — add to /etc/wsl.conf:
[boot]
systemd=true
then from a WINDOWS terminal (not this shell): wsl --shutdown")
fi
# WSL regenerates /etc/resolv.conf on every boot, which silently reverts any
# local DNS setup.
if [ -r "$wc" ] && grep -qE '^\s*generateResolvConf\s*=\s*false' "$wc"; then
echo " resolv.conf pinned (generateResolvConf=false)"
else
echo " - resolv.conf is WSL-generated; DNS_MODE=dnsmasq would be reverted on reboot"
fi
local wcfg
wcfg=$(ls "$HOST_ROOT"/mnt/c/Users/*/.wslconfig 2>/dev/null | head -1 || true)
if [ -n "$wcfg" ] && grep -qE '^\s*memory\s*=' "$wcfg"; then
echo " wslconfig memory set: $(grep -E '^\s*memory\s*=' "$wcfg" | tr -d ' ')"
else
MANUAL+=("Cap/raise the WSL VM memory — in %USERPROFILE%\\.wslconfig on Windows:
[wsl2]
memory=8GB
then from a WINDOWS terminal: wsl --shutdown")
fi
}
detect_docker() {
# Reachability of the daemon is the real question, and the CLI is only how
# we ask it. Note that when this runs inside the wizard container, Docker
# necessarily exists on the host — otherwise nothing would be executing —
# so a missing CLI in here is a wizard packaging bug, not a host problem.
if ! command -v docker >/dev/null 2>&1; then
if [ -S /var/run/docker.sock ]; then
echo " docker socket present (no cli in this context)"
else
echo " ! docker not found and no socket at /var/run/docker.sock"
MANUAL+=("Install Docker — the one true prerequisite:
sudo apt-get install -y docker.io && sudo usermod -aG docker \"\$USER\"
then log out and back in.")
fi
return
fi
if docker info >/dev/null 2>&1; then
echo " docker $(docker version --format '{{.Server.Version}}' 2>/dev/null)"
local n
n=$(docker ps --filter "label=io.x-k8s.kind.cluster" --format '{{.Names}}' 2>/dev/null | wc -l)
# Must be an `if`, not `[ ] && echo`: as the last statement in this
# function the latter returns 1 when the count is zero, and `set -e`
# then kills the caller. That is the fresh-machine case — no clusters
# yet — so the bug only ever shows up where it does most harm.
if [ "$n" -gt 0 ]; then
echo " - $n kind node container(s) already running; see 'make cluster list'"
fi
else
echo " ! docker cli present but the daemon is unreachable"
MANUAL+=("Start Docker, or add yourself to the docker group:
sudo usermod -aG docker \"\$USER\" # then log out and back in")
fi
}
# kind and Tilt both watch large trees. WSL ships defaults (8192/128) far too low,
# and the failure mode is silent: Tilt simply stops noticing file changes.
detect_inotify() {
local w i
w=$(cat /proc/sys/fs/inotify/max_user_watches 2>/dev/null || echo 0)
i=$(cat /proc/sys/fs/inotify/max_user_instances 2>/dev/null || echo 0)
echo " inotify watches=$w instances=$i"
if [ "$w" -lt 524288 ] || [ "$i" -lt 512 ]; then
echo " ! inotify limits are low — Tilt will silently stop noticing file changes"
MANUAL+=("Raise inotify limits (needs root on the host):
echo -e 'fs.inotify.max_user_watches=524288\\nfs.inotify.max_user_instances=512' \\
| sudo tee /etc/sysctl.d/99-rig.conf
sudo sysctl --system")
fi
}
# ── fetch ──────────────────────────────────────────────────────────────────
# Resolve where a given artifact comes from, honouring DEPS_SOURCE.
resolve_url() {
local upstream="$1"
case "$DEPS_SOURCE" in
upstream) echo "$upstream" ;;
artifactory)
if [ -z "$DEPS_ARTIFACTORY_URL" ]; then
echo "DEPS_SOURCE=artifactory but DEPS_ARTIFACTORY_URL is empty" >&2
exit 1
fi
echo "${DEPS_ARTIFACTORY_URL%/}/$(basename "$upstream")"
;;
*) echo "unsupported DEPS_SOURCE '$DEPS_SOURCE' for a download" >&2; exit 1 ;;
esac
}
verify() {
local file="$1" want="$2" name="$3" got
got=$(sha256sum "$file" | awk '{print $1}')
if [ "$got" != "$want" ]; then
echo "checksum mismatch for $name" >&2
echo " expected $want" >&2
echo " got $got" >&2
exit 1
fi
}
# fetch_bin <name> <url> <sha256> <dest-dir> — a bare binary
fetch_bin() {
local name="$1" url="$2" sha="$3" dest="$4"
local tmp="$dest/.$name.tmp"
echo " fetching $name"
curl -fsSL --retry 3 -o "$tmp" "$(resolve_url "$url")"
verify "$tmp" "$sha" "$name"
mv "$tmp" "$dest/$name"
chmod +x "$dest/$name"
}
# fetch_tgz <name> <url> <sha256> <dest-dir> <path-inside-archive> <strip>
# Archive layouts differ — tilt's is flat (the binary at the root, strip=0),
# others nest it a directory down — so the caller says which.
fetch_tgz() {
local name="$1" url="$2" sha="$3" dest="$4" inner="$5" strip="$6"
local tmp="$dest/.$name.tgz"
echo " fetching $name"
curl -fsSL --retry 3 -o "$tmp" "$(resolve_url "$url")"
verify "$tmp" "$sha" "$name"
# --no-same-owner: extracting as root would otherwise restore the uid/gid
# baked into the archive (some ship as uid 1001), leaving a binary the host
# user does not own.
tar -xzf "$tmp" -C "$dest" --strip-components="$strip" --no-same-owner "$inner"
rm -f "$tmp"
chmod +x "$dest/$name"
}
# The wizard runs as root so it can reach the docker socket, which means
# everything it writes into a mounted volume lands root-owned and unusable from
# the host. Hand it back to whoever owns the mount point (the host user created
# that directory before mounting it).
fix_ownership() {
local dir="$1"
[ -d "$dir" ] || return 0
local owner="${HOST_UID:-}:${HOST_GID:-}"
if [ "$owner" = ":" ]; then
owner=$(stat -c '%u:%g' "$dir")
fi
[ "$owner" = "0:0" ] && return 0
chown -R "$owner" "$dir" 2>/dev/null || true
}
# Two tiers, because not every machine should get cluster tooling.
#
# core kubectl, jq — talk to a cluster someone else runs. Nothing that
# creates one. Appropriate on a managed or corporate-issued machine
# where development tools are not wanted by default.
# dev core plus kind and tilt — build clusters and hot-reload into them.
#
# The split exists because "install the toolchain" is not one decision: on a
# managed workspace the right answer is kubectl and nothing else.
CORE_TOOLS="kubectl jq"
# No helm: every addon installs with `kubectl apply -f <url>`, so nothing here
# has ever invoked it. Add it back the day something actually needs a chart.
DEV_TOOLS="kind tilt"
fetch() {
local dest="$OUT_BIN" tier="${TIER:-dev}"
while [ $# -gt 0 ]; do
case "$1" in
--to) dest="$2"; shift 2 ;;
core|dev) tier="$1"; shift ;;
*) echo "unknown argument: $1" >&2; exit 1 ;;
esac
done
dest="$(abspath "$dest")"
mkdir -p "$dest"
TIER="$tier"
if [ "$DEPS_SOURCE" = "baked" ]; then
echo "installing baked binaries from $BAKED_BIN"
cp -a "$BAKED_BIN"/. "$dest"/
fix_ownership "$dest"
return
fi
echo "fetching '$tier' toolchain (source: $DEPS_SOURCE)"
fetch_bin kubectl "$KUBECTL_URL" "$KUBECTL_SHA256" "$dest"
fetch_bin jq "$JQ_URL" "$JQ_SHA256" "$dest"
if [ "$tier" = "dev" ]; then
fetch_bin kind "$KIND_URL" "$KIND_SHA256" "$dest"
fetch_tgz tilt "$TILT_URL" "$TILT_SHA256" "$dest" tilt 0
fi
fix_ownership "$dest"
# kind writes the kubeconfig as root too; hand that back as well when it's
# a mounted host directory rather than container-local state.
fix_ownership "${KUBE_DIR:-/out/kube}"
}
# ── install ────────────────────────────────────────────────────────────────
report_manual() {
echo
if [ ${#MANUAL[@]} -eq 0 ]; then
echo "nothing left to do by hand."
return
fi
echo "host actions the wizard cannot perform (${#MANUAL[@]}):"
echo
local n=1
for m in "${MANUAL[@]}"; do
echo " $n. $m"
echo
n=$((n + 1))
done
}
# Installing into a directory that sits early in PATH silently replaces whatever
# the machine was already using — which on a shared or client machine can break
# unrelated work (kubectl more than one minor away from a cluster is the common
# one). Say so; never decide it for them.
tier_tools() { [ "$1" = "core" ] && echo "$CORE_TOOLS" || echo "$CORE_TOOLS $DEV_TOOLS"; }
warn_shadowing() {
local b existing shadowed="" tier="${1:-dev}"
for b in $(tier_tools "$tier"); do
[ -x "$OUT_BIN/$b" ] || continue
# Where would this resolve if OUT_BIN weren't in the way?
existing=$(PATH=$(echo "$PATH" | tr ':' '\n' | grep -vx "$OUT_BIN" | paste -sd:) \
command -v "$b" 2>/dev/null || true)
[ -n "$existing" ] || continue
[ "$existing" = "$OUT_BIN/$b" ] && continue
shadowed+=" $b $existing"$'\n'
done
[ -n "$shadowed" ] || return 0
case ":${PATH}:" in
*":$OUT_BIN:"*) ;;
*) return 0 ;; # not on PATH yet, so nothing is being shadowed
esac
echo
echo " ! these were already installed elsewhere and are now shadowed by $OUT_BIN:"
printf '%s' "$shadowed"
echo " Other projects on this machine will pick up the new versions."
MANUAL+=("Decide which toolchain wins. To keep the previous one, remove what
was just installed:
rm -f $(for b in $(tier_tools "$tier"); do printf '%s ' "$OUT_BIN/$b"; done)
Or install somewhere private instead:
OUT_BIN=\$PWD/def/bin make deps # then put that dir first in PATH")
}
install() {
local tier="${1:-dev}"
detect
echo
fetch "$tier"
echo
echo "installed to $OUT_BIN ($tier):"
for b in $(tier_tools "$tier"); do
[ -x "$OUT_BIN/$b" ] && echo " $b"
done
if [ "$tier" = "core" ]; then
echo " (no kind/tilt — 'make deps dev' adds them)"
fi
warn_shadowing "$tier"
case ":${PATH}:" in
*":$OUT_BIN:"*) ;;
*) MANUAL+=("Put the toolchain on your PATH — add to ~/.bashrc:
export PATH=\"${OUT_BIN}:\$PATH\"") ;;
esac
report_manual
}
# ── main ───────────────────────────────────────────────────────────────────
case "${1:-install}" in
detect) detect; report_manual ;;
fetch) shift; fetch "$@" ;;
install) shift; install "${1:-dev}" ;;
*) echo "usage: $0 [detect|fetch|install]" >&2; exit 1 ;;
esac