#!/usr/bin/env bash # The rig bundle: generate it, deploy it, tear it down, find it. # # Usage: bundle.sh manifest | up | down | status | url | list | dev # # What `up` proves, in order: kind installed and a cluster exists, MetalLB can # hand out an address, a Service of type LoadBalancer actually resolves, and a # pod serves the bundle listing. If all of that works the installation is sound, # and the only thing missing is the real architecture. # # ONE ARTIFACT # `up` applies generated/.yaml — the same self-contained file you would # hand to an external cluster. There is no separate local path, so what works # here cannot quietly differ from the master deployment applied elsewhere. # # ONE CLUSTER, SEVERAL RIGS # Identity follows the FOLDER NAME, exactly as rig's cluster identity does. This # directory deploys into a namespace named after itself, so copying it to # corporate-rig/ yields a second rig in the SAME local cluster with no edits and # no collisions — different namespace, its own MetalLB address. `list` shows all # of them. The cluster itself is rig's business; this only ever owns a namespace. # # MetalLB is installed by calling rig's own addon script rather than # reimplementing it — deriving the pool from the kind Docker network is the # fiddly part and there should be exactly one copy of it. set -euo pipefail cd "$(dirname "$0")/.." BUNDLE_ROOT="$(pwd)" RIG_CTRL="$(cd .. && pwd)/ctrl" # The containing folder's name, reduced to a DNS label (same rule as rig's # default_cluster_name and ctrl/manifest.py, so all three agree on the slug). slug() { local n n=$(basename "$BUNDLE_ROOT") n=$(echo "$n" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-') n=$(echo "$n" | sed 's/^-*//; s/-*$//') echo "${n:-rig-bundle}" } NS="$(slug)" ARTIFACT="generated/${NS}.yaml" # Resolved lazily, not at load time: `manifest` and `dev` deliberately work # with no cluster and no kubectl at all, and a top-level check would break that. # # Follows whatever context rig's cluster.sh selected, so this bundle works in a # copied-and-renamed environment without being told which cluster it is in. init_kube() { KUBECONTEXT="${KUBECONTEXT:-$(kubectl config current-context 2>/dev/null || true)}" if [ -z "$KUBECONTEXT" ]; then echo "no kubectl context — bring a cluster up first: (cd .. && make cluster up)" >&2 exit 1 fi KCTX="kubectl --context ${KUBECONTEXT}" K="kubectl --context ${KUBECONTEXT} --namespace ${NS}" } require_cluster() { if ! $KCTX cluster-info >/dev/null 2>&1; then echo "context '$KUBECONTEXT' does not reach a cluster" >&2 echo "bring one up: (cd .. && make cluster up)" >&2 exit 1 fi } ensure_metallb() { if $KCTX get deployment -n metallb-system controller >/dev/null 2>&1; then echo "metallb: present" return 0 fi # Only kind needs it. On a real cluster the cloud load balancer answers a # `type: LoadBalancer` Service, and installing MetalLB there would be wrong. case "$KUBECONTEXT" in kind-*) ;; *) echo "metallb: skipped — '$KUBECONTEXT' is not a kind context" echo " (a cloud load balancer answers LoadBalancer services there)" return 0 ;; esac if [ ! -f "$RIG_CTRL/addons/metallb.sh" ]; then echo "metallb is not installed and rig's addon script was not found at" >&2 echo " $RIG_CTRL/addons/metallb.sh" >&2 echo "a Service of type LoadBalancer will sit at without it." >&2 exit 1 fi # rig's addons derive their target cluster from RIG'S OWN folder name via # load_config, so left alone this bundle would install into `kind-rig` — # a cluster that need not exist — while deploying everything else into the # context actually selected. CLUSTER is in load_config's overridable set, # so passing it here points the addon at the same cluster we are using. local target="${KUBECONTEXT#kind-}" echo "metallb: installing via rig's addon into '$target'" CLUSTER="$target" bash "$RIG_CTRL/addons/metallb.sh" } # Regenerate the artifact. No cluster and no kubectl required — this is the step # a staging workstation runs before anything is installed. manifest() { mkdir -p generated python3 ctrl/manifest.py "$NS" > "$ARTIFACT" echo "wrote $ARTIFACT ($(wc -l < "$ARTIFACT") lines)" echo " applies as-is anywhere: kubectl apply -f ${BUNDLE_ROOT}/${ARTIFACT}" } up() { manifest init_kube require_cluster ensure_metallb echo echo "applying '${NS}' to context '${KUBECONTEXT}'" $KCTX apply -f "$ARTIFACT" # `rollout status` does not work on a bare Pod — it only understands # Deployments, StatefulSets and DaemonSets. Wait on the condition instead. # This is the slow step: the container npm-installs before vite serves. echo "waiting for the pod to be ready (npm install runs first)..." $K wait --for=condition=Ready pod/rig-ui --timeout=300s echo url } down() { init_kube # Delete the namespace and everything in it goes with it. Scoped to THIS # rig — a sibling rig in the same cluster is untouched. $KCTX delete namespace "$NS" --ignore-not-found echo "'${NS}' removed (cluster, metallb and any sibling rig are left alone)" } status() { init_kube require_cluster if ! $KCTX get namespace "$NS" >/dev/null 2>&1; then echo "'${NS}' is not deployed — run: make up" return 0 fi $K get pod,svc,configmap -o wide } # Every rig in this cluster, not just this one — the point of the namespace # split is that several coexist, so there has to be a way to see them together. list() { init_kube require_cluster local names names=$($KCTX get namespace -l rig.bundle/name \ -o jsonpath='{.items[*].metadata.name}' 2>/dev/null || true) if [ -z "$names" ]; then echo "no rigs deployed in context '${KUBECONTEXT}'" return 0 fi printf "%-20s %-16s %s\n" RIG ADDRESS "" local n ip for n in $names; do ip=$($KCTX -n "$n" get svc rig-ui \ -o jsonpath='{.status.loadBalancer.ingress[0].ip}' 2>/dev/null || true) printf "%-20s %-16s %s\n" "$n" "${ip:-}" \ "$([ "$n" = "$NS" ] && echo '<- this one')" done } # The address MetalLB (or a cloud load balancer) assigned. here is the # classic silent failure: everything reports healthy and nothing is reachable. url() { init_kube local ip ip=$($K get svc rig-ui \ -o jsonpath='{.status.loadBalancer.ingress[0].ip}' 2>/dev/null || true) if [ -z "$ip" ]; then ip=$($K get svc rig-ui \ -o jsonpath='{.status.loadBalancer.ingress[0].hostname}' 2>/dev/null || true) fi if [ -z "$ip" ]; then echo "no external address yet — nothing has assigned one." echo "on kind: kubectl --context $KUBECONTEXT -n metallb-system get pods" return 1 fi echo "IT WORKS -> http://${ip}/" echo " bundle http://${ip}/bundle.json" } # Run the UI locally with no cluster at all — the fast way to iterate on # bundle.json. Same vite command the pod runs, so what you see here is what # gets served there. dev() { if ! command -v npm >/dev/null 2>&1; then echo "npm not found — the UI needs node locally for this." >&2 echo "(in-cluster it runs on the node:22-alpine image instead)" >&2 exit 1 fi # bundle.json lives one level up so it stays the rig's data rather than the # app's; vite serves public/ at the root, which is where the app fetches it. mkdir -p rig-ui/public cp bundle.json rig-ui/public/bundle.json # The mocked cluster is a DEMO asset and is deliberately not embedded in the # deployed artifact — on a real rig the UI would then show canned values # beside a live cluster, which is precisely the lie its banner warns about. # It is served here, and in the static build for the public UI-only page. cp cluster.mock.json rig-ui/public/cluster.mock.json cd rig-ui [ -d node_modules ] || npm install --no-audit --no-fund VITE_RIG_NAME="$NS" npm run dev } case "${1:-status}" in manifest) manifest ;; up) up ;; down) down ;; status) status ;; url) url ;; list) list ;; dev) dev ;; *) echo "usage: $0 [manifest|up|down|status|url|list|dev]" >&2; exit 1 ;; esac