rig major updates

This commit is contained in:
2026-09-22 05:15:49 -03:00
parent 9c963514f1
commit 2a0a793f19
64 changed files with 1762 additions and 645 deletions

View File

@@ -6,6 +6,11 @@
# need no profile at all. Copy an env.d/*.env.example to <name>.env to add one.
PROFILE=
# The overlay: one folder, outside rig's version control, holding what runs —
# its settings (rig.env), manifests, addons, Tiltfile. Relative to rig's folder,
# or absolute. Unset: rig's own examples/starter. See docs/notes/overlay.md.
# OVERLAY=local/<name>
# Cluster name; the kubectl context becomes kind-<CLUSTER>.
# LEAVE UNSET: it defaults to this folder's name, which keeps the folder copyable.
# CLUSTER=
@@ -17,9 +22,8 @@ PROFILE=
# TILT_PORT=
# REGISTRY_PORT=
# Where the application manifests live; repoint at their own repo, e.g.
# MANIFESTS_DIR=../platform-manifests/overlays/dev
MANIFESTS_DIR=ctrl/k8s/overlays/dev
# Where the manifests live. Leave unset: the overlay's k8s/overlays/dev.
# MANIFESTS_DIR=../platform-manifests/overlays/dev
# Where the installer fetches the pinned binaries from:
# upstream (needs internet) | artifactory (generic repo) | baked (in the image)

View File

@@ -27,8 +27,10 @@ CMD ["install"]
# ---------------------------------------------------------------------------
# deps-full — same image, binaries baked in, works with no network at all.
# deps-full — same image, binaries and the addons' manifests baked in, works with no network.
FROM deps AS deps-full
RUN /work/rigdeps.sh fetch --to /opt/rig/bin
RUN /work/rigdeps.sh fetch --to /opt/rig/bin \
&& /work/rigdeps.sh manifests --to /opt/rig/manifests
ENV DEPS_SOURCE=baked \
BAKED_BIN=/opt/rig/bin
BAKED_BIN=/opt/rig/bin \
BAKED_MANIFESTS=/opt/rig/manifests

View File

@@ -1,24 +0,0 @@
# EXAMPLE component image. Copy, rename, replace:
# ctrl/Dockerfile.api -> image <cluster>-api -> image: in k8s/base/api.yaml
# COPY paths are relative to the REPO ROOT (Tilt context='..'), not this directory.
# Notes: docs/notes/Dockerfile.example.md
FROM python:3.12-slim
WORKDIR /app
# Dependencies first, in their own layer, so a source edit does not reinstall them.
COPY api/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Repo-root relative — see above.
COPY api/ ./api/
# Match this with the containerPort in the manifest and the target of the
# Service in front of it.
EXPOSE 8000
CMD ["python", "-m", "api"]
# live_update: Tiltfile's sync('../api', '/app/api') must match COPY api/ + WORKDIR /app,
# or edits silently do nothing.

View File

@@ -1,6 +1,6 @@
# The dev loop. `make tilt` from the project root, or `cd ctrl && tilt up`.
# Works unedited; replace the two EXAMPLES and add yours in the marked sections.
# rig supplies this file but does not own it, and nothing here names this directory.
# The dev loop, rig's half. `make tilt` from rig's folder, or from an overlay's forwarder.
# rig owns this file: who we are, the context guard, the registry, the overlay's manifests.
# The workload's half is the overlay's own Tiltfile, included at the end; edit that one.
# Notes: docs/notes/Tiltfile.md
# ── who we are, and on which ports ─────────────────────────────────────────
@@ -12,10 +12,9 @@ HTTP = _facts[2]
HTTPS = _facts[3]
TILT = _facts[4]
REGISTRY = _facts[5]
# Where the manifests live (MANIFESTS_DIR, see k8s/README.md).
# The value is REPO-ROOT relative and this file runs in ctrl/, so prefix '../'.
MANIFESTS = '../' + _facts[6]
# Absolute paths, or '-' when there is none.
MANIFESTS = '' if _facts[6] == '-' else _facts[6]
OVERLAY = '' if _facts[7] == '-' else _facts[7]
# ── refuse to deploy into the wrong cluster ────────────────────────────────
# Tilt fixes the context before parsing this file, so it can only be refused here.
@@ -25,73 +24,44 @@ if k8s_context() != CTX:
fail("Wrong kubectl context: '%s'. This is %s — run: make tilt, or tilt up --context %s"
% (k8s_context(), CLUSTER, CTX))
# The namespace has to exist before anything lands in it, and kustomize does not
# guarantee ordering across resources. Creating it here is idempotent.
local('kubectl --context %s create namespace %s --dry-run=client -o yaml | kubectl --context %s apply -f -'
% (CTX, CLUSTER, CTX), quiet=True)
# ── images go to this environment's own registry ───────────────────────────
# Fail closed: name the registry rather than let Tilt infer it, or a miss pushes
# an unqualified image to docker.io.
default_registry('localhost:' + REGISTRY)
k8s_yaml(kustomize(MANIFESTS))
# ── Images ─────────────────────────────────────────────────────────────────
# (nothing yet — rig's examples run upstream images. Add docker_build calls here.)
# ── Resources ──────────────────────────────────────────────────────────────
# (nothing yet — add k8s_resource calls here to name and order what you deploy.)
# Everything with no dev loop of its own, gathered so it does not clutter the UI.
k8s_resource(
objects=[CLUSTER + ':namespace'],
new_name='infra',
)
# ── the overlay's manifests ────────────────────────────────────────────────
# Every namespace they use must exist before anything lands in it, and kustomize
# does not order resources, so create them here (idempotent). The Namespaces they
# declare are grouped as 'infra', whatever they are named.
if MANIFESTS:
_yaml = kustomize(MANIFESTS)
k8s_yaml(_yaml)
_declared = []
_namespaces = {}
for _o in decode_yaml_stream(_yaml):
if not _o:
continue
_md = _o.get('metadata') or {}
if _o.get('kind') == 'Namespace':
_declared.append(_md.get('name'))
_namespaces[_md.get('name')] = True
elif _md.get('namespace'):
_namespaces[_md.get('namespace')] = True
for _ns in sorted(_namespaces.keys()):
local('kubectl --context %s create namespace %s --dry-run=client -o yaml | kubectl --context %s apply -f -'
% (CTX, _ns, CTX), quiet=True)
if _declared:
k8s_resource(objects=[_n + ':namespace' for _n in _declared], new_name='infra')
# ═══════════════════════════════════════════════════════════════════════════
# Catalogue — paste what you need, delete the rest.
# Commented out so this file runs as-is.
# ═══════════════════════════════════════════════════════════════════════════
#
# ── build an image ─────────────────────────────────────────────────────────
# context= is the REPO ROOT ('..'); dockerfile= is relative to THIS file (ctrl/).
# So every COPY is repo-root relative, even for files beside the Dockerfile.
#
# docker_build(
# CLUSTER + '-api', # must match `image:` in the manifest —
# context='..', # that string is the only thing
# dockerfile='Dockerfile.api', # connecting the two
# ignore=['.git', 'def', '.venv', 'node_modules', '__pycache__'],
# live_update=[sync('../api', '/app/api')],
# )
#
# ── name and order a resource ──────────────────────────────────────────────
# k8s_resource('api', resource_deps=['postgres'], labels=['app'])
# k8s_resource('gateway', resource_deps=['api', 'ui'], labels=['app'])
#
# ── reload the gateway when its config changes ─────────────────────────────
# A hash-less configMapGenerator ConfigMap never changes name, so edits do NOT
# roll the pod on their own.
#
# local_resource(
# 'gateway-reload',
# cmd='kubectl --context %s -n %s rollout restart deployment/gateway' % (CTX, CLUSTER),
# deps=['k8s/base/Caddyfile'],
# resource_deps=['gateway'],
# auto_init=False,
# )
#
# ── an overlay whose secretGenerator reads outside its own directory ───────
# kustomize refuses to read above the kustomization root unless told to. Only
# add this if you actually have such a generator; it loosens a safety check.
#
# k8s_yaml(kustomize(MANIFESTS, flags=['--load-restrictor=LoadRestrictionsNone']))
#
# ── reach a service directly, bypassing the gateway ────────────────────────
# Prefer the gateway; host ports are shared machine-wide. If you need one, take it
# from this environment's own port block.
#
# k8s_resource('postgres', port_forwards=[str(int(HTTP) + 5) + ':5432'])
# ── the workload's half: the overlay's Tiltfile ────────────────────────────
# Included, so its relative paths resolve from the overlay's own folder. It reads
# these facts with os.getenv and never needs a path back into rig.
os.putenv('RIG_CLUSTER', CLUSTER)
os.putenv('RIG_CONTEXT', CTX)
os.putenv('RIG_HTTP_PORT', HTTP)
os.putenv('RIG_HTTPS_PORT', HTTPS)
os.putenv('RIG_TILT_PORT', TILT)
os.putenv('RIG_REGISTRY', 'localhost:' + REGISTRY)
os.putenv('RIG_OVERLAY_DIR', OVERLAY)
if OVERLAY and os.path.exists(OVERLAY + '/Tiltfile'):
include(OVERLAY + '/Tiltfile')

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Install the addons the active profile asked for, in the order listed.
# One idempotent script per addon in ctrl/addons/.
# Install the addons the configuration asks for (ADDONS), in the order listed.
# One idempotent script per addon: the overlay's addons/<name>.sh first, then rig's ctrl/addons/.
# Usage: addons.sh install | list
# Notes: docs/notes/addons.md
set -euo pipefail
@@ -9,26 +9,51 @@ cd "$(dirname "$0")"
source ./lib/config.sh
load_config
# Every addon runs from here, wherever its file lives, so it can source ./lib/config.sh.
export RIG_CTRL="$PWD"
# The script for one addon name: the overlay's, else rig's; empty if neither.
addon_path() {
local ov=""
if [ -n "$OVERLAY_DIR" ]; then ov="$(_from_ctrl "$OVERLAY_DIR")/addons/$1.sh"; fi
if [ -n "$ov" ] && [ -f "$ov" ]; then
echo "$ov"
elif [ -f "addons/$1.sh" ]; then
echo "addons/$1.sh"
fi
}
install() {
if [ -z "${ADDONS// /}" ]; then
echo "no addons in profile '$PROFILE_NAME'"
echo "no addons asked for (ADDONS is empty)"
return
fi
local a
local a p
for a in $ADDONS; do
if [ ! -f "addons/${a}.sh" ]; then
echo "no such addon: addons/${a}.sh" >&2
p=$(addon_path "$a")
if [ -z "$p" ]; then
echo "no such addon: $a (looked in the overlay's addons/ and ctrl/addons/)" >&2
exit 1
fi
echo "addon: $a"
bash "addons/${a}.sh"
bash "$p"
done
}
list() {
echo "profile '$PROFILE_NAME' wants: ${ADDONS:-none}"
echo "wanted: ${ADDONS:-none}"
echo "available:"
ls addons/*.sh 2>/dev/null | xargs -n1 basename | sed 's/\.sh$//' | sed 's/^/ /'
local f
if [ -n "$OVERLAY_DIR" ]; then
for f in "$(_from_ctrl "$OVERLAY_DIR")"/addons/*.sh; do
[ -e "$f" ] || continue
printf ' %-16s overlay\n' "$(basename "$f" .sh)"
done
fi
for f in addons/*.sh; do
[ -e "$f" ] || continue
printf ' %-16s rig\n' "$(basename "$f" .sh)"
done
}
case "${1:-list}" in

View File

@@ -1,109 +0,0 @@
#!/usr/bin/env bash
# Apache Airflow — the cluster half of the airflow cabinet (one `standalone` pod).
# Requires the postgres addon; refuses to install without it.
# Notes: docs/notes/addons.md
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"

View File

@@ -2,7 +2,7 @@
# cert-manager plus a self-signed cluster issuer (offline local CA).
# Notes: docs/notes/addons.md
set -euo pipefail
cd "$(dirname "$0")/.."
cd "${RIG_CTRL:-$(dirname "$0")/..}"
source ./lib/config.sh
load_config
@@ -12,7 +12,9 @@ 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"
# The pinned manifest, verified on disk — never a URL applied directly.
manifest=$(bash ./deps.sh manifest CERT_MANAGER)
$K apply -f "$manifest"
fi
echo " waiting for cert-manager..."

View File

@@ -3,7 +3,7 @@
# The pool is derived from the kind Docker network at install time.
# Notes: docs/notes/addons.md
set -euo pipefail
cd "$(dirname "$0")/.."
cd "${RIG_CTRL:-$(dirname "$0")/..}"
source ./lib/config.sh
load_config
@@ -46,7 +46,9 @@ echo " kind network $subnet → pool ${pool_start}-${pool_end}"
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"
# The pinned manifest, verified on disk — never a URL applied directly.
manifest=$(bash ./deps.sh manifest METALLB)
$K apply -f "$manifest"
fi
# `rollout status`, not `kubectl wait`: wait errors out while the pod doesn't exist yet.

View File

@@ -2,7 +2,7 @@
# metrics-server — makes `kubectl top` work (patched with --kubelet-insecure-tls for kind).
# Notes: docs/notes/addons.md
set -euo pipefail
cd "$(dirname "$0")/.."
cd "${RIG_CTRL:-$(dirname "$0")/..}"
source ./lib/config.sh
load_config
@@ -10,7 +10,9 @@ 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"
# The pinned manifest, verified on disk — never a URL applied directly.
manifest=$(bash ./deps.sh manifest METRICS_SERVER)
$K apply -f "$manifest"
fi
$K patch deployment metrics-server -n kube-system --type=json \

View File

@@ -1,105 +0,0 @@
#!/usr/bin/env bash
# PostgreSQL — the cluster half of the postgres cabinet: plain manifests, one replica on a PVC.
# Notes: docs/notes/addons.md
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:-postgres}" \
--from-literal=POSTGRES_USER="${POSTGRES_USER:-postgres}" \
--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"

View File

@@ -1,57 +0,0 @@
#!/usr/bin/env bash
# Redis — the cluster half of the redis cabinet: cache/broker, no persistence.
# Notes: docs/notes/addons.md
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"

View File

@@ -69,10 +69,34 @@ port_busy() {
echo
echo "rig"
echo " cluster ${CLUSTER} (${KUBECONTEXT}) profile ${PROFILE_NAME}, ${NODES} node(s), registry ${REGISTRY_MODE}"
if [ -n "${OVERLAY:-}" ]; then
echo " overlay $(basename "$(_abs_from_ctrl "$OVERLAY_DIR")") ($(_abs_from_ctrl "$OVERLAY_DIR"))"
elif [ -n "$OVERLAY_DIR" ]; then
fact " overlay none named — rig's own ${OVERLAY_DIR}"
fi
if [ -n "$VERBOSE" ] && [ -n "$OVERLAY_DIR" ]; then
ov=$(_from_ctrl "$OVERLAY_DIR") pieces=""
for piece in rig.env k8s/overlays/dev kind-config.yaml.tpl addons Tiltfile; do
[ -e "$ov/$piece" ] && pieces+="$piece "
done
echo " provides: ${pieces:-nothing rig reads}"
fi
fact " manifests ${MANIFESTS_DIR:-none}"
fact " kind config ${KIND_CONFIG}"
fact " ingress ${INGRESS_MODE}"
if [ ! -f ./.env ]; then
fact " .env none — built-in defaults (cp ctrl/.env.example ctrl/.env to set values)"
fi
if [ -n "${STALE_MANIFESTS_DIR:-}" ]; then
echo " ! .env MANIFESTS_DIR=${STALE_MANIFESTS_DIR} is the old default; rig's examples moved"
echo " to examples/ — delete that line from ctrl/.env (ignored until then)"
fi
# registry.sh points containerd at certs.d, which only works if the kind config says so,
# and a kind config is fixed at creation: a project's own file that drops it fails silently.
if [ "$REGISTRY_MODE" != none ] && ! grep -q 'config_path *= *"/etc/containerd/certs.d"' "$KIND_CONFIG"; then
echo " ! kind ${KIND_CONFIG} lacks the containerd config_path patch that registry mode"
echo " '${REGISTRY_MODE}' needs — copy it from ctrl/k8s/kind-config.yaml.tpl"
fi
# ── memory: does this cluster fit right now? Warns; never blocks. ──────────
total_mb=$(mb_of MemTotal)
@@ -157,7 +181,11 @@ elif [ "$mine" -eq 1 ]; then
else
echo " ports ${list% } free"
fi
fact " derived from the directory name; pin them: bash ctrl/ports.sh persist"
if [ -n "${OVERLAY:-}" ]; then
fact " derived from the overlay's folder name"
else
fact " derived from the directory name; pin them: bash ctrl/ports.sh persist"
fi
# ── what `make cluster up` wires in beside the cluster ─────────────────────
REG_NAME="${CLUSTER}-registry"
@@ -167,7 +195,9 @@ else
fact " registry no container yet — 'make cluster up' starts it"
fi
echo " addons ${ADDONS:-none}"
fact " available: $(ls addons/*.sh 2>/dev/null | xargs -n1 basename | sed 's/\.sh$//' | tr '\n' ' ')"
if [ -n "$VERBOSE" ]; then
bash ./addons.sh list | sed -n '3,$p' | sed 's/^/ /'
fi
# The CA reaches three places and only one of them is ours. Report the other two.
if [ -n "${REGISTRY_CA_FILE:-}" ]; then

View File

@@ -15,6 +15,7 @@ up() {
# Say what this profile locks in BEFORE spending minutes building it:
# the kind config is fixed at creation and cannot be changed later.
echo "creating cluster '$CLUSTER' from profile '$PROFILE_NAME'"
echo " overlay ${OVERLAY_DIR:-none}"
echo " kind config ${KIND_CONFIG}"
echo " nodes $NODES"
echo " image $NODE_IMAGE"

View File

@@ -2,7 +2,8 @@
# rig:standalone rigdeps detect
# Toolchain installer: detect the host, install pinned tools into $OUT_BIN, report
# host actions it will not perform (no sudo, no apt). Usually via `make deps`.
# Usage: deps.sh [detect [all] | list | verify [core|dev] | fetch [core|dev] [--to DIR] | install [core|dev]]
# Usage: deps.sh [detect [all] | list | verify [core|dev] | fetch [core|dev] [--to DIR] | install [core|dev]
# | manifest NAME | manifests [--to DIR]]
# Notes: docs/notes/deps.md
set -euo pipefail
@@ -719,6 +720,51 @@ require_linux
cmd="${1:-install}"
[ $# -gt 0 ] && shift
# ── manifests rig's own addons apply ───────────────────────────────────────
# Pinned (URL + SHA256), fetched through the same DEPS_SOURCE resolver as the
# binaries and verified, then applied from disk: an offline machine needs no
# network for them. Default home: vendor/manifests/ in rig's folder (gitignored).
MANIFESTS_HOME="${MANIFESTS_HOME:-$(cd .. && pwd)/vendor/manifests}"
BAKED_MANIFESTS="${BAKED_MANIFESTS:-/opt/rig/manifests}"
MANIFEST_NAMES="METALLB CERT_MANAGER METRICS_SERVER"
manifest_path() { # NAME dir
local v="${1}_VERSION"
echo "$2/$(echo "$1" | tr 'A-Z_' 'a-z-')-${!v}.yaml"
}
# Make one pinned manifest present and verified in dir; print only its path.
fetch_manifest() { # NAME dir
local name="$1" dir="$2" url_var="${1}_MANIFEST_URL" sha_var="${1}_MANIFEST_SHA256" file
if [ -z "${!url_var:-}" ] || [ -z "${!sha_var:-}" ]; then
echo "no pinned manifest for $name (${url_var} / ${sha_var} unset)" >&2
exit 1
fi
file=$(manifest_path "$name" "$dir")
if [ -f "$file" ] && [ "$($SHA "$file" | awk '{print $1}')" = "${!sha_var}" ]; then
echo "$file"
return
fi
mkdir -p "$dir"
if [ "$DEPS_SOURCE" = baked ]; then
cp "$(manifest_path "$name" "$BAKED_MANIFESTS")" "$file.tmp"
else
download "$(resolve_url "${!url_var}")" "$file.tmp"
fi
verify "$file.tmp" "${!sha_var}" "$name manifest"
mv "$file.tmp" "$file"
echo "$file"
}
fetch_manifests() { # [--to DIR]
local dest="$MANIFESTS_HOME" n
if [ "${1:-}" = --to ]; then dest="$(abspath "${2:?--to needs a directory}")"; fi
echo "fetching the addons' manifests into $dest (source: $DEPS_SOURCE)"
for n in $MANIFEST_NAMES; do
echo " $n $(fetch_manifest "$n" "$dest")"
done
}
# Baked mode copies binaries already in the image, so it needs no downloader.
need_downloads() {
require_amd64
@@ -732,9 +778,13 @@ case "$cmd" in
verify) verify_tools "${1:-dev}" ;;
fetch) need_downloads; fetch "$@" ;;
install) need_downloads; install "${1:-dev}" ;;
*) echo "usage: $0 [detect [all]|list|verify|fetch|install]" >&2
manifest) need_downloads
fetch_manifest "${1:?usage: $0 manifest <METALLB|CERT_MANAGER|METRICS_SERVER>}" "$MANIFESTS_HOME" ;;
manifests) need_downloads; fetch_manifests "$@" ;;
*) echo "usage: $0 [detect [all]|list|verify|fetch|install|manifest NAME|manifests]" >&2
echo " install [core|dev] (default dev)" >&2
echo " fetch [core|dev] [--to DIR]" >&2
echo " manifests [--to DIR] the addons' pinned manifests, verified" >&2
echo " OUT_BIN=<dir> overrides the install directory" >&2
exit 1 ;;
esac

View File

@@ -1,26 +0,0 @@
# EXAMPLE PROFILE (optional): copy to data.env, then PROFILE=data; overlays the defaults.
# data — postgres, redis and airflow (upstream images) in the `data` namespace.
# Notes: docs/notes/env.md
PROFILE_NAME=data
K8S_VERSION=v1_36
# 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 the defaults in lib/config.sh: `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 generated once by postgres.sh and kept.
POSTGRES_DB=app
POSTGRES_USER=app
POSTGRES_STORAGE=2Gi
AIRFLOW_ADMIN_USER=admin
# Reach the databases with port-forward, e.g.
# kubectl -n data port-forward svc/postgres 5432:5432

View File

@@ -1,8 +1,9 @@
# EXAMPLE PROFILE (optional): copy to client.env, then PROFILE=client; overlays the defaults.
# client — images via a pull-through cache of the corporate registry, TLS and metrics addons.
# EXAMPLE PROFILE (optional): copy to mirror.env, then PROFILE=mirror; overlays the defaults.
# mirror — images via a pull-through cache of an internal registry, TLS and metrics addons.
# A profile says how this machine reaches the world; what runs is an overlay's business.
# Notes: docs/notes/env.md
PROFILE_NAME=client
PROFILE_NAME=mirror
K8S_VERSION=v1_36
ADDONS="metallb cert-manager metrics-server"
REGISTRY_MODE=mirror
@@ -15,6 +16,6 @@ DNS_MODE=hosts
# HTTPS_PORT=443
# Set these in ctrl/.env (gitignored), not here:
# REGISTRY_REMOTE_URL=https://artifactory.corp.example/artifactory/api/docker/docker-virtual
# REGISTRY_REMOTE_URL=https://registry.internal.example/api/docker/docker-virtual
# REGISTRY_USER / REGISTRY_PASSWORD
# REGISTRY_CA_FILE=/path/to/corp-root-ca.crt
# REGISTRY_CA_FILE=/path/to/internal-root-ca.crt

View File

@@ -1,60 +0,0 @@
# `ctrl/k8s` — the cluster, and what runs on it
Same layout as every other project here: a kind config, a kustomize `base/`,
and an `overlays/dev/` that patches it.
```
kind-config.yaml.tpl the cluster itself — nodes, ports
base/ the components, as plain manifests
overlays/dev/ how this rig differs from the base
```
## 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.
**To change the cluster, edit this file** — more nodes, other port mappings —
then `make cluster reset`: a kind config is fixed at creation, not re-applied.
`lib/config.sh` reads the node count back out of it, so nothing restates it.
A project that builds its own cluster through rig passes its own file as
`KIND_CONFIG=<path>`; it is rendered the same way.
## `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

@@ -1,104 +0,0 @@
# 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

@@ -1,46 +0,0 @@
# 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

@@ -1,11 +0,0 @@
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

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

View File

@@ -1,5 +1,5 @@
# The cluster. Add nodes or port mappings here, then `make cluster reset`.
# ctrl/cluster.sh substitutes (sed): CLUSTER, NODE_IMAGE, HTTP_PORT, HOST_WORKDIR
# ctrl/cluster.sh substitutes (sed): CLUSTER, NODE_IMAGE, HTTP_PORT, HOST_WORKDIR, OVERLAY_DIR
# lib/config.sh reads the node count back from this file.
# Notes: docs/notes/kind-config.md
kind: Cluster

View File

@@ -1,22 +0,0 @@
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

View File

@@ -1,19 +1,34 @@
# Shared config loading: how the config layers compose. Sourced, never executed.
# Precedence, weakest first: defaults < versions.env < env.d/<profile> < .env < caller's env.
# Precedence, weakest first:
# defaults < versions.env < env.d/<profile> < <overlay>/rig.env < .env < caller's env.
# Run from ctrl/.
# Notes: docs/notes/config.md
# Per-invocation overrides: restored after the files are read, so the caller wins.
# NODES is deliberately not here (read from the kind config).
CONFIG_OVERRIDABLE="PROFILE CLUSTER K8S_VERSION KIND_CONFIG ADDONS
CONFIG_OVERRIDABLE="PROFILE OVERLAY CLUSTER K8S_VERSION KIND_CONFIG ADDONS
REGISTRY_MODE INGRESS_MODE DNS_MODE TILT_PORT
SOURCE ARCH DEPS_SOURCE HTTP_PORT HTTPS_PORT
REGISTRY_PORT MANIFESTS_DIR"
# The repo folder's name, reduced to a DNS label kind accepts as a cluster name.
# rig's own example, used when no overlay is named (relative to rig's root).
DEFAULT_OVERLAY=examples/starter
# A rig-root-relative path as seen from ctrl/; absolute paths pass through.
_from_ctrl() { case "$1" in /*) echo "$1" ;; *) echo "../$1" ;; esac; }
# The same, absolute. Empty if it does not exist.
_abs_from_ctrl() { (cd "$(_from_ctrl "$1")" 2>/dev/null && pwd); }
# The environment's folder — the overlay's when one is named, else rig's —
# reduced to a DNS label kind accepts as a cluster name.
default_cluster_name() {
local n
n=$(basename "$(cd .. && pwd)")
if [ -n "${OVERLAY:-}" ]; then
n=$(basename "$(_abs_from_ctrl "$OVERLAY_DIR")")
else
n=$(basename "$(cd .. && pwd)")
fi
n=$(echo "$n" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-')
n=$(echo "$n" | sed 's/^-*//; s/-*$//')
echo "${n:-rig}"
@@ -45,15 +60,59 @@ load_config() {
_config_restore "$saved"
# A profile is optional; naming one that does not exist is an error.
local profile="${PROFILE:-}"
local profile="${PROFILE:-}" layered=""
if [ -n "$profile" ] && [ "$profile" != default ]; then
if [ ! -f "./env.d/${profile}.env" ]; then
echo "no such profile: env.d/${profile}.env" >&2
if [ -d "../examples/${profile}" ]; then
echo " it is an example overlay now: OVERLAY=examples/${profile}" >&2
fi
echo "available: $(config_profiles | tr '\n' ' ')" >&2
exit 1
fi
set -a
source "./env.d/${profile}.env"
set +a
layered=1
fi
# The overlay: one folder, outside rig, holding a use case (docs/notes/overlay.md).
# Named ones must exist; with none named, rig's own example is used if present.
OVERLAY_DIR=""
if [ -n "${OVERLAY:-}" ]; then
OVERLAY_DIR="${OVERLAY%/}"
if [ ! -d "$(_from_ctrl "$OVERLAY_DIR")" ]; then
echo "no overlay at OVERLAY=${OVERLAY} (relative to rig's folder, or absolute)" >&2
exit 1
fi
elif [ -d "../${DEFAULT_OVERLAY}" ]; then
OVERLAY_DIR="$DEFAULT_OVERLAY"
fi
# Its rig.env may not choose the profile or the overlay (both are chosen before
# it loads), and the paths it sets are relative to the overlay.
local ov_env="" m_before k_before
if [ -n "$OVERLAY_DIR" ]; then ov_env="$(_from_ctrl "$OVERLAY_DIR")/rig.env"; fi
if [ -n "$ov_env" ] && [ -f "$ov_env" ]; then
if grep -qE '^[[:space:]]*(export[[:space:]]+)?(PROFILE|OVERLAY)=' "$ov_env"; then
echo "$ov_env: an overlay's rig.env cannot set PROFILE or OVERLAY (they choose it)" >&2
exit 1
fi
m_before="${MANIFESTS_DIR-}" k_before="${KIND_CONFIG-}"
set -a
source "$ov_env"
set +a
if [ "${MANIFESTS_DIR-}" != "$m_before" ]; then
case "$MANIFESTS_DIR" in /*|none|"") ;; *) MANIFESTS_DIR="${OVERLAY_DIR}/${MANIFESTS_DIR}" ;; esac
fi
if [ "${KIND_CONFIG-}" != "$k_before" ]; then
case "$KIND_CONFIG" in /*|"") ;; *) KIND_CONFIG="$(dirname "$ov_env")/${KIND_CONFIG}" ;; esac
fi
layered=1
fi
# The machine and the caller still win over both.
if [ -n "$layered" ]; then
set -a
if [ -z "${RIG_PORTABLE:-}" ] && [ -f ./.env ]; then source ./.env; fi
set +a
_config_restore "$saved"
@@ -86,9 +145,25 @@ load_config() {
TILT_PORT="${TILT_PORT:-$((base + 2))}"
REGISTRY_PORT="${REGISTRY_PORT:-$((base + 3))}"
# Where the workload's manifests live, repo-root relative; always resolved.
# See k8s/README.md.
MANIFESTS_DIR="${MANIFESTS_DIR:-ctrl/k8s/overlays/dev}"
# Where the workload's manifests live, relative to rig's folder (or absolute):
# the overlay's k8s/overlays/dev unless something names another. `none`: rig
# applies none (the overlay's Tiltfile does). A named folder must exist.
if [ "${MANIFESTS_DIR:-}" = ctrl/k8s/overlays/dev ] && [ ! -d ../ctrl/k8s/overlays/dev ]; then
# The old default, pinned by an older .env.example; rig's examples moved.
STALE_MANIFESTS_DIR="$MANIFESTS_DIR"
MANIFESTS_DIR=""
fi
if [ -z "${MANIFESTS_DIR:-}" ] && [ -n "$OVERLAY_DIR" ] \
&& [ -d "$(_from_ctrl "$OVERLAY_DIR")/k8s/overlays/dev" ]; then
MANIFESTS_DIR="$OVERLAY_DIR/k8s/overlays/dev"
fi
MANIFESTS_DIR="${MANIFESTS_DIR:-}"
if [ "$MANIFESTS_DIR" = none ]; then
MANIFESTS_DIR=""
elif [ -n "$MANIFESTS_DIR" ] && [ ! -d "$(_from_ctrl "$MANIFESTS_DIR")" ]; then
echo "no manifests at MANIFESTS_DIR=${MANIFESTS_DIR} (relative to rig's folder, or absolute)" >&2
exit 1
fi
# Profiles name a k8s minor (v1_36); versions.env holds the pinned digest.
local var="NODE_IMAGE_${K8S_VERSION}"
@@ -98,9 +173,13 @@ load_config() {
exit 1
fi
# The cluster is one file: k8s/kind-config.yaml.tpl. To change it, edit it.
# KIND_CONFIG is only "use this file instead", for a project that builds its
# own cluster through rig (a path relative to ctrl/, or absolute).
# The cluster is one file: the overlay's kind-config.yaml.tpl if it has one,
# else rig's k8s/kind-config.yaml.tpl. KIND_CONFIG is "use this file instead",
# for a project that builds its own cluster through rig (relative to ctrl/, or absolute).
if [ -z "${KIND_CONFIG:-}" ] && [ -n "$OVERLAY_DIR" ] \
&& [ -f "$(_from_ctrl "$OVERLAY_DIR")/kind-config.yaml.tpl" ]; then
KIND_CONFIG="$(_from_ctrl "$OVERLAY_DIR")/kind-config.yaml.tpl"
fi
KIND_CONFIG="${KIND_CONFIG:-./k8s/kind-config.yaml.tpl}"
if [ ! -f "$KIND_CONFIG" ]; then
echo "no kind config at KIND_CONFIG=${KIND_CONFIG}" >&2
@@ -117,13 +196,15 @@ load_config() {
}
# Render the kind config to stdout with sed (not envsubst) over an explicit variable list.
# HOST_WORKDIR must be a host path: the host dockerd resolves hostPath entries.
# HOST_WORKDIR and OVERLAY_DIR must be host paths: the host dockerd resolves hostPath entries.
render_kind_config() {
local host_workdir="${HOST_WORKDIR:-$(cd .. && pwd)}"
local host_workdir="${HOST_WORKDIR:-$(cd .. && pwd)}" overlay_dir=""
if [ -n "$OVERLAY_DIR" ]; then overlay_dir=$(_abs_from_ctrl "$OVERLAY_DIR"); fi
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" \
-e "s|\${OVERLAY_DIR}|${overlay_dir}|g" \
"$KIND_CONFIG"
}

View File

@@ -22,11 +22,19 @@ derive() {
}
# Resolved facts for consumers outside bash, space-separated, positional:
# CLUSTER KUBECONTEXT HTTP HTTPS TILT REGISTRY MANIFESTS_DIR
# Read this, not `derive` (which ignores ctrl/.env).
# CLUSTER KUBECONTEXT HTTP HTTPS TILT REGISTRY MANIFESTS_DIR OVERLAY_DIR
# The two paths are absolute, or - when there is none. Read this, not `derive`.
active() {
load_config
echo "$CLUSTER $KUBECONTEXT $HTTP_PORT $HTTPS_PORT $TILT_PORT $REGISTRY_PORT $MANIFESTS_DIR"
local m="-" o="-"
if [ -n "$MANIFESTS_DIR" ]; then m=$(_abs_from_ctrl "$MANIFESTS_DIR"); fi
if [ -n "$OVERLAY_DIR" ]; then o=$(_abs_from_ctrl "$OVERLAY_DIR"); fi
case "$m$o" in
*[[:space:]]*)
echo "a path here holds whitespace, and these facts are split on spaces: $m $o" >&2
exit 1 ;;
esac
echo "$CLUSTER $KUBECONTEXT $HTTP_PORT $HTTPS_PORT $TILT_PORT $REGISTRY_PORT $m $o"
}
show() {
@@ -55,6 +63,13 @@ _row() {
# rewritten — an override stays an override.
persist() {
derive
# ctrl/.env belongs to this rig, not to an overlay: a pin written now would
# follow every overlay this rig later runs, and two of them would collide.
if [ -n "${OVERLAY:-}" ]; then
echo "not pinning: OVERLAY is set, and ctrl/.env would carry this block to every overlay" >&2
echo " its ports stay derived from its folder name ($CLUSTER): $DERIVED_HTTP-$DERIVED_REGISTRY" >&2
exit 1
fi
[ -f ./.env ] || cp ./.env.example ./.env
local wrote=0 key val

View File

@@ -23,6 +23,17 @@ check() { # name, expected, actual
note() { printf '\n%s\n' "$1"; }
# A scratch copy of rig for a check to change freely. local/ (overlays, possibly
# someone else's) and def/ (scratch) never ride along, and neither do this
# machine's PROFILE/OVERLAY/CLUSTER choices: a check sets what it tests.
copy_rig() { # dest-dir
mkdir -p "$1"
tar -C .. --exclude=./local --exclude=./def -cf - . | tar -C "$1" -xf -
if [ -f "$1/ctrl/.env" ]; then
sed -i '/^PROFILE=/d; /^OVERLAY=/d; /^CLUSTER=/d; /^MANIFESTS_DIR=/d' "$1/ctrl/.env"
fi
}
# Resolve one key the way every rig script does, in a clean shell so the
# caller's exported value is the only thing in play.
resolved() {
@@ -33,12 +44,12 @@ resolved() {
note "rig needs no profile"
# No env.d/ must still resolve and generate a kit; an unknown profile stays an error.
NP="$(mktemp -d)"
cp -r .. "$NP/rig"; rm -rf "$NP/rig/ctrl/env.d"; sed -i '/^PROFILE=/d' "$NP/rig/ctrl/.env" 2>/dev/null
copy_rig "$NP/rig"; rm -rf "$NP/rig/ctrl/env.d"
check "no env.d: config resolves" "default" \
"$(cd "$NP/rig/ctrl" && bash -c 'source ./lib/config.sh; load_config >/dev/null && echo "$PROFILE_NAME"' 2>&1)"
check "no env.d: the k8s version comes from the pins" "yes" \
"$(cd "$NP/rig/ctrl" && bash -c 'source ./lib/config.sh; load_config >/dev/null && [ -n "$NODE_IMAGE" ] && echo yes' 2>&1)"
check "no env.d: ports.sh active works" "7" \
check "no env.d: ports.sh active works" "8" \
"$(cd "$NP/rig/ctrl" && bash ports.sh active 2>/dev/null | wc -w)"
check "no env.d: a kit is generated for the defaults" "yes" \
"$( (cd "$NP/rig/ctrl" && rm -rf ../standalone/*/ && bash standalone.sh write >/dev/null 2>&1) && [ -f "$NP/rig/standalone/default/rigdeps.sh" ] && echo yes || echo no)"
@@ -50,13 +61,14 @@ rm -rf "$NP"
note "the ports.sh active contract"
# ports.sh active is read positionally by the Makefile and Tiltfile: pin field count and order.
FACTS="$(bash ports.sh active)"
check "active: exactly 7 fields" "7" "$(printf '%s' "$FACTS" | wc -w)"
read -r F_CLUSTER F_CTX F_HTTP F_HTTPS F_TILT F_REG F_MANIFESTS <<< "$FACTS"
check "active: exactly 8 fields" "8" "$(printf '%s' "$FACTS" | wc -w)"
read -r F_CLUSTER F_CTX F_HTTP F_HTTPS F_TILT F_REG F_MANIFESTS F_OVERLAY <<< "$FACTS"
check "active: field 2 is kind-<cluster>" "kind-$F_CLUSTER" "$F_CTX"
check "active: fields 3-6 are numeric" "yes" \
"$([[ "$F_HTTP$F_HTTPS$F_TILT$F_REG" =~ ^[0-9]+$ ]] && echo yes || echo no)"
check "active: field 7 is a path" "yes" \
"$([ -n "$F_MANIFESTS" ] && [ "${F_MANIFESTS#-}" = "$F_MANIFESTS" ] && echo yes || echo no)"
# Absolute, or - when there is none: an empty field would shift every later one.
check "active: fields 7-8 are absolute paths or -" "yes" \
"$(for f in "$F_MANIFESTS" "$F_OVERLAY"; do case "$f" in -|/*) ;; *) echo no; exit; esac; done; echo yes)"
# derive answers a different question and must keep its own shape: it reports
# what the directory name implies, ignoring ctrl/.env, so nothing should
# configure itself from it.
@@ -76,7 +88,9 @@ test_value() {
KIND_CONFIG) echo "$PWD/k8s/kind-config.yaml.tpl" ;;
*_PORT) echo "19999" ;;
CLUSTER) echo "selftest-name" ;;
MANIFESTS_DIR) echo "../elsewhere/overlays/dev" ;;
# Named folders must exist, and must not be the default.
MANIFESTS_DIR) echo "examples/starter/k8s/base" ;;
OVERLAY) echo "examples/data" ;;
ADDONS) echo "metallb" ;;
*) echo "selftest-sentinel" ;;
esac
@@ -110,8 +124,8 @@ trap 'rm -rf "$TMP"' EXIT
mkdir -p "$TMP/My_Proj"
cp -r . "$TMP/My_Proj/ctrl"
# A pinned CLUSTER in .env would be an override, not a derivation, and this
# check is about the derivation.
sed -i '/^CLUSTER=/d' "$TMP/My_Proj/ctrl/.env" 2>/dev/null
# check is about the derivation. (An OVERLAY would be another derivation.)
sed -i '/^CLUSTER=/d; /^OVERLAY=/d' "$TMP/My_Proj/ctrl/.env" 2>/dev/null
COPY="$(cd "$TMP/My_Proj/ctrl" && bash ports.sh active)"
check "a dir named My_Proj derives a DNS label" "my-proj" "$(awk '{print $1}' <<< "$COPY")"
check "and a context to match" "kind-my-proj" "$(awk '{print $2}' <<< "$COPY")"
@@ -129,9 +143,88 @@ check "derive_port_base my-proj" "21030" "$(derive_port_base my-proj)"
note "rig stays standalone"
# rig must be copyable out of its host project: no references to the host.
# The pattern is assembled from fragments so this file does not match itself.
HOST_PAT="$(printf '%s' 'sole' 'print' '|\b' 'sp' 'r\b')"
# The host project's word for a backing service counts too: rig described its
# workload addons with it until they left. local/ holds overlays, which may say anything.
HOST_PAT="$(printf '%s' 'sole' 'print' '|\b' 'sp' 'r\b' '|' 'cab' 'inet')"
check "no host-project references" "0" \
"$(cd .. && grep -rIl -iE "$HOST_PAT" . --exclude-dir=def 2>/dev/null | wc -l)"
"$(cd .. && grep -rIl -iE "$HOST_PAT" . --exclude-dir=def --exclude-dir=local 2>/dev/null | wc -l)"
note "what runs is an overlay; rig only reads it"
# docs/notes/overlay.md. Every check runs in a scratch copy with its own overlay.
OV="$TMP/overlay-proof"; copy_rig "$OV/rig"
OVR="$OV/rig"
mkdir -p "$OVR/local/My_Env/addons" "$OVR/local/My_Env/k8s/prod" "$OVR/ctrl/env.d"
printf 'ADDONS="from-profile"\nDATA_NAMESPACE=from-profile\n' > "$OVR/ctrl/env.d/selftest.env"
cat > "$OVR/local/My_Env/rig.env" <<'EOF'
ADDONS="metallb"
DATA_NAMESPACE=from-overlay
MANIFESTS_DIR=k8s/prod
SELFTEST_SENTINEL=selftest-overlay-sentinel
EOF
printf 'resources: []\n' > "$OVR/local/My_Env/k8s/prod/kustomization.yaml"
printf '#!/usr/bin/env bash\necho "overlay-metallb from $PWD with ${RIG_CTRL:-no RIG_CTRL}"\n' \
> "$OVR/local/My_Env/addons/metallb.sh"
in_ov() { (cd "$OVR/ctrl" && "$@"); }
ov_key() { # key [env assignments...]
local k="$1"; shift
in_ov env "$@" bash -c 'source ./lib/config.sh; load_config >/dev/null 2>&1; printf "%s" "${!1}"' _ "$k"
}
# With nothing named, rig behaves as it did before overlays: same name, ports, addons, nodes.
check "no overlay: the cluster, ports and addons of before" "rig kind-rig 20310 20311 20312 20313" \
"$(in_ov bash ports.sh active | awk '{print $1, $2, $3, $4, $5, $6}')"
check "no overlay: no addons, one node, rig's own kind config" "|1|./k8s/kind-config.yaml.tpl" \
"$(ov_key ADDONS)|$(ov_key NODES)|$(ov_key KIND_CONFIG)"
# The overlay's rig.env sits between the profile and ctrl/.env; the caller beats all.
check "rig.env beats the profile" "from-overlay" \
"$(ov_key DATA_NAMESPACE PROFILE=selftest OVERLAY=local/My_Env)"
echo 'DATA_NAMESPACE=from-dotenv' >> "$OVR/ctrl/.env"
check "ctrl/.env beats rig.env" "from-dotenv" \
"$(ov_key DATA_NAMESPACE PROFILE=selftest OVERLAY=local/My_Env)"
sed -i '/^DATA_NAMESPACE=from-dotenv$/d' "$OVR/ctrl/.env"
check "the caller beats rig.env" "from-caller" \
"$(ov_key ADDONS OVERLAY=local/My_Env ADDONS=from-caller)"
# Identity follows the overlay's folder, so one rig serves several without collisions.
check "identity follows the overlay's folder" "my-env kind-my-env" \
"$(in_ov env OVERLAY=local/My_Env bash ports.sh active | awk '{print $1, $2}')"
check "paths in rig.env are relative to the overlay" "$OVR/local/My_Env/k8s/prod" \
"$(in_ov env OVERLAY=local/My_Env bash ports.sh active | awk '{print $7}')"
check "a named overlay that does not exist is an error" "yes" \
"$(in_ov env OVERLAY=local/nope bash ports.sh active >/dev/null 2>&1 && echo no || echo yes)"
printf 'PROFILE=x\n' > "$OV/bad-rig.env"; mkdir -p "$OVR/local/bad"; cp "$OV/bad-rig.env" "$OVR/local/bad/rig.env"
check "rig.env may not choose the profile or the overlay" "yes" \
"$(in_ov env OVERLAY=local/bad bash ports.sh active >/dev/null 2>&1 && echo no || echo yes)"
# Addons: the overlay's is found before rig's own, and runs from rig's ctrl/.
check "an overlay's addon comes before rig's of the same name" \
"overlay-metallb from $OVR/ctrl with $OVR/ctrl" \
"$(in_ov env OVERLAY=local/My_Env ADDONS=metallb bash addons.sh install 2>&1 | grep '^overlay-metallb')"
# ctrl/.env is this rig's: a pinned block would follow every overlay.
check "ports.sh persist refuses while an overlay is set" "yes" \
"$(in_ov env OVERLAY=local/My_Env bash ports.sh persist >/dev/null 2>&1 && echo no || echo yes)"
# make's $(shell) must see an OVERLAY given as a make argument (make < 4.4 does not pass it).
check "make -n tilt OVERLAY=... asks for the overlay's context" "kind-data" \
"$(cd .. && make --no-print-directory -n tilt OVERLAY=examples/data 2>/dev/null | grep -m1 'tilt ' | sed -n 's/.*--context \([^ ]*\).*/\1/p')"
# rig reads an overlay and never writes into it; its values never reach a committed kit.
sum_ov() { (cd "$OVR/local/My_Env" && find . -type f | sort | xargs sha256sum | sha256sum); }
before=$(sum_ov)
echo 'OVERLAY=local/My_Env' >> "$OVR/ctrl/.env"
in_ov bash ports.sh active >/dev/null 2>&1
in_ov bash addons.sh list >/dev/null 2>&1
in_ov bash -c 'source ./lib/config.sh; load_config >/dev/null; render_kind_config >/dev/null' 2>/dev/null
in_ov bash standalone.sh write >/dev/null 2>&1
in_ov bash standalone.sh export "$OV/export" >/dev/null 2>&1
check "rig writes nothing into an overlay" "$before" "$(sum_ov)"
check "an overlay's values never reach a committed kit" "0" \
"$(grep -rlE 'selftest-overlay-sentinel|local/My_Env' "$OVR/standalone" 2>/dev/null | wc -l)"
check "a committed kit holds no path of this machine" "0" \
"$(grep -rlF "$OVR" "$OVR/standalone" 2>/dev/null | wc -l)"
note "the Tiltfile hardcodes nothing"
@@ -139,6 +232,7 @@ note "the Tiltfile hardcodes nothing"
check "no literal kind-<name>" "0" "$(grep -cE "['\"]kind-[a-z0-9]" Tiltfile)"
check "guards on the variable" "1" "$(grep -c 'allow_k8s_contexts(CTX)' Tiltfile)"
check "asks ports.sh for facts" "1" "$(grep -c "local('bash ports.sh active'" Tiltfile)"
check "hands over to the overlay's Tiltfile" "1" "$(grep -c "include(OVERLAY + '/Tiltfile')" Tiltfile)"
note "standalone kits are generated, current, and call only real verbs"
@@ -168,7 +262,8 @@ check "there is a kit for every profile" "$(config_profiles | wc -l)" "$kits"
# An export carries this machine's choices but never its credentials; committed kits carry neither.
# Proven with sentinel values in a scratch copy, since the real ctrl/.env may leave them empty.
SX="$TMP/export-proof"; mkdir -p "$SX"; cp -r .. "$SX/rig"
SX="$TMP/export-proof"; copy_rig "$SX/rig"
mkdir -p "$SX/selftest-sentinel-choice/overlays/dev" # a named MANIFESTS_DIR must exist
cat >> "$SX/rig/ctrl/.env" <<'EOF'
REGISTRY_USER=selftest-sentinel-user
REGISTRY_PASSWORD=selftest-sentinel-password
@@ -187,16 +282,87 @@ check "export: refuses to write inside the repository" "yes" \
"$( (bash standalone.sh export ../standalone/selftest-mine >/dev/null 2>&1) && echo no || echo yes)"
note "optional — needs tilt and this rig's cluster"
# Tilt needs a cluster context to parse the Tiltfile, so this is skipped without one.
if ! command -v tilt >/dev/null; then
printf ' skip tilt is not installed\n'
elif ! kubectl config get-contexts -o name 2>/dev/null | grep -qx "$F_CTX"; then
printf " skip no %s context — run 'make cluster up' to include this\n" "$F_CTX"
note "rig's addons apply verified files, never URLs"
# The offline profile must need no network for manifests: each addon asks deps.sh for a
# pinned manifest, verified on disk (versions.md). Their images still need preloading.
check "no rig addon applies a URL" "0" \
"$(cat addons/*.sh | grep -cE 'apply -f "?https?://')"
check "every manifest an addon asks for is pinned with a sum" "" \
"$(for n in $(grep -ohE 'deps\.sh manifest [A-Z_]+' addons/*.sh | awk '{print $3}' | sort -u); do
grep -q "^${n}_MANIFEST_URL=" versions.env && grep -q "^${n}_MANIFEST_SHA256=[0-9a-f]\{64\}$" versions.env \
|| printf '%s ' "$n"; done)"
note "withdrawn stays withdrawn (STALE.md)"
# One check per entry; the reasoning is in STALE.md, not here.
check "✖ S1 rig's Tiltfile has no Images section of its own" "0" "$(grep -c '^# ── Images' Tiltfile)"
check "✖ S2 local/ is where overlays live, and ignored" "yes" \
"$(grep -qx '/local/' ../.gitignore && echo yes || echo no)"
# Patterns assembled from fragments so this file does not match itself.
COPIES_PAT="$(printf '%s' 'ac' 'me-rig|ac' 'mebank')"
HOUSE_PAT="$(printf '%s' 'semes' 'ter|local' '\.ar\b')"
check "✖ S2 no example environment name from the copies era" "0" \
"$(cd .. && grep -rIlE "$COPIES_PAT" . --exclude-dir=def --exclude-dir=local --exclude=STALE.md 2>/dev/null | wc -l)"
check "✖ S3 ctrl/addons makes the cluster work, nothing more" "cert-manager metallb metrics-server" \
"$(ls addons/ | sed 's/\.sh$//' | sort | xargs)"
check "✖ S3 versions.env pins no workload image" "0" \
"$(grep -cE '^(POSTGRES|REDIS|AIRFLOW)_IMAGE=' versions.env)"
check "✖ S4 no namespace named after the cluster" "0" "$(grep -c "CLUSTER + ':namespace'" Tiltfile)"
check "✖ S5 rig's examples left ctrl/k8s" "no" "$([ -d k8s/overlays ] && echo yes || echo no)"
check "✖ S5 .env.example does not pin MANIFESTS_DIR" "0" "$(grep -c '^MANIFESTS_DIR=' .env.example)"
check "✖ S6 no client or data example profile" "0" \
"$(ls env.d/ | grep -cE '^(client|data)\.')"
check "✖ S7 no house path or host name in rig" "0" \
"$(cd .. && grep -rIlE "$HOUSE_PAT" . --exclude-dir=def --exclude-dir=local --exclude=STALE.md 2>/dev/null | wc -l)"
note "the dev loop parses — needs tilt and kubectl, not a cluster"
# A throwaway kubeconfig with kind-named entries (Tilt trusts kind contexts) and a
# kubectl that swallows `apply`: the Tiltfile evaluates for real, nothing is contacted.
# The second run is a copy under another name, the case that once failed at load.
if ! command -v tilt >/dev/null || ! command -v kubectl >/dev/null; then
printf ' skip tilt or kubectl is not installed\n'
else
FK="$TMP/fake-kube"; mkdir -p "$FK"
real_kubectl=$(command -v kubectl)
printf '#!/usr/bin/env bash\nfor a in "$@"; do [ "$a" = apply ] && { cat >/dev/null; exit 0; }; done\nexec %q "$@"\n' \
"$real_kubectl" > "$FK/kubectl"
chmod +x "$FK/kubectl"
parses() { # cluster-name [env...] -> the manifests Tilt would deploy, or the error
local name="$1"; shift
cat > "$FK/kubeconfig" <<EOF
apiVersion: v1
kind: Config
clusters: [{name: kind-$name, cluster: {server: "https://127.0.0.1:9"}}]
contexts: [{name: kind-$name, context: {cluster: kind-$name, user: kind-$name}}]
users: [{name: kind-$name, user: {token: selftest}}]
current-context: kind-$name
EOF
env "$@" KUBECONFIG="$FK/kubeconfig" PATH="$FK:$PATH" \
timeout 120 tilt alpha tiltfile-result --context "kind-$name" > "$FK/out.json" 2> "$FK/err" \
&& grep -o '"Name": *"[^"]*"' "$FK/out.json" | sed 's/.*"\([^"]*\)"$/\1/' | sort -u | xargs \
|| grep -m1 -iE 'error|no object' "$FK/err"
}
check "the starter overlay parses" "example-service infra uncategorized" "$(parses rig)"
check "and under another name" "example-service infra uncategorized" \
"$(parses selftest-copy CLUSTER=selftest-copy)"
check "the data overlay parses" "items-api uncategorized" "$(parses data OVERLAY=examples/data)"
fi
note "the examples are overlays that work as shipped"
# They are what a real overlay is copied from, so they must at least parse.
bad=""
for f in ../examples/*/addons/*.sh; do [ -e "$f" ] && { bash -n "$f" 2>/dev/null || bad+="$f "; }; done
check "every example addon parses" "" "$bad"
if command -v python3 >/dev/null; then
bad=""
for f in ../examples/*/dags/*.py; do
[ -e "$f" ] && { python3 -c 'import ast, sys; ast.parse(open(sys.argv[1]).read())' "$f" 2>/dev/null || bad+="$f "; }
done
check "every example DAG parses" "" "$bad"
else
out="$(tilt alpha tiltfile-result --context "$F_CTX" 2>&1)"
check "Tiltfile evaluates" "yes" \
"$(printf '%s' "$out" | grep -q '"Manifests"' && echo yes || echo "no: $(printf '%s' "$out" | tail -1)")"
printf ' skip python3 is not installed\n'
fi

View File

@@ -30,25 +30,28 @@ COMPOSE_VERSION=5.5.1
COMPOSE_SHA256=db1889184726840f75c4f9c001048430d4f25b3be3cb084d3ddd762bc0aed576
COMPOSE_URL=https://github.com/docker/compose/releases/download/v${COMPOSE_VERSION}/docker-compose-linux-x86_64
# Node images for KIND_VERSION, pinned by digest; profiles pick one via K8S_VERSION.
# Older entries are kept deliberately (legacy-estate simulation).
# Node images for KIND_VERSION, pinned by digest; K8S_VERSION picks one (default: the newest).
# Older entries are kept deliberately, for targets that run an older Kubernetes.
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.
# Images pulled at runtime. 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.
# rig's own addons (ctrl/addons/<name>.sh), installed when ADDONS names them.
CERT_MANAGER_VERSION=v1.21.1
METRICS_SERVER_VERSION=v0.9.0
METALLB_VERSION=v0.16.0
# Cabinets — unmodified upstream images, usable on compose or in the cluster.
# Pinned by tag; 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
# The manifests those addons apply, fetched and verified like the binaries
# (`deps.sh manifest <NAME>`), so an offline machine needs no network for them.
# Sums from the release's own asset digest; metallb publishes none, see versions.md.
CERT_MANAGER_MANIFEST_URL=https://github.com/cert-manager/cert-manager/releases/download/${CERT_MANAGER_VERSION}/cert-manager.yaml
CERT_MANAGER_MANIFEST_SHA256=5f6a499b8c1857d57f560f536e0dcc830914b45c420899fe7ad0692c8624e408
METRICS_SERVER_MANIFEST_URL=https://github.com/kubernetes-sigs/metrics-server/releases/download/${METRICS_SERVER_VERSION}/components.yaml
METRICS_SERVER_MANIFEST_SHA256=1cec29a5267809306a2c6ec74a3e449abbb705b4a8beed0c8a1963910f72c79b
METALLB_MANIFEST_URL=https://raw.githubusercontent.com/metallb/metallb/${METALLB_VERSION}/config/manifests/metallb-native.yaml
METALLB_MANIFEST_SHA256=b0b9be2802f10aa32d45308b4457d06cde0c70544712c8d0cf5511657ffd2b69
METALLB_MANIFEST_GIT_BLOB=7fbda334cc3ac0aaabdcb081af4f543feb3c2f9f