init rig
This commit is contained in:
223
rig/sample-rig/ctrl/bundle.sh
Executable file
223
rig/sample-rig/ctrl/bundle.sh
Executable file
@@ -0,0 +1,223 @@
|
||||
#!/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/<slug>.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 <pending> 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:-<pending>}" \
|
||||
"$([ "$n" = "$NS" ] && echo '<- this one')"
|
||||
done
|
||||
}
|
||||
|
||||
# The address MetalLB (or a cloud load balancer) assigned. <pending> 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
|
||||
141
rig/sample-rig/ctrl/manifest.py
Normal file
141
rig/sample-rig/ctrl/manifest.py
Normal file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Emit the complete, self-contained deployment for this rig.
|
||||
|
||||
python3 ctrl/manifest.py [namespace] > generated/<slug>.yaml
|
||||
|
||||
The output is the ARTIFACT. It carries everything — namespace, the vite app and
|
||||
bundle.json embedded in a ConfigMap, the Pod and the Service — so it applies
|
||||
unmodified to any cluster:
|
||||
|
||||
kubectl apply -f generated/sample-rig.yaml
|
||||
|
||||
On kind, MetalLB answers the `type: LoadBalancer` Service. On a real external
|
||||
cluster the cloud load balancer does. Same file, no edits, no branch — which is
|
||||
the point: what runs locally is byte-identical to the deployment applied
|
||||
elsewhere, so local success actually means something.
|
||||
|
||||
`ctrl/bundle.sh up` applies this same generated output rather than a separate
|
||||
code path, so the local convenience wrapper can never drift from the artifact.
|
||||
|
||||
Stdlib only, deliberately: this must run on a bare staging workstation before
|
||||
anything is installed, so it cannot depend on PyYAML or a template engine.
|
||||
|
||||
Open seam — not built: everything here derives from bundle.json plus a target
|
||||
namespace. A Pulumi or Terraform emitter would sit beside this file consuming the
|
||||
same inputs; nothing above it assumes the artifact is YAML.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
UI = ROOT / "rig-ui"
|
||||
|
||||
# Files embedded into the ConfigMap, mounted read-only at /src in the pod and
|
||||
# copied into vite's layout at start (see rig-ui/k8s.yaml). Flat on purpose:
|
||||
# ConfigMap keys cannot contain '/'.
|
||||
EMBEDDED = {
|
||||
"bundle.json": ROOT / "bundle.json",
|
||||
"package.json": UI / "package.json",
|
||||
"vite.config.js": UI / "vite.config.js",
|
||||
"index.html": UI / "index.html",
|
||||
"main.js": UI / "src" / "main.js",
|
||||
"style.css": UI / "src" / "style.css",
|
||||
}
|
||||
|
||||
|
||||
def slug(name: str) -> str:
|
||||
"""Reduce a folder name to a DNS label, matching ctrl/bundle.sh's rule."""
|
||||
out = re.sub(r"[^a-z0-9-]", "-", name.lower()).strip("-")
|
||||
return out or "rig-bundle"
|
||||
|
||||
|
||||
def block(text: str, indent: int) -> str:
|
||||
"""Indent a file's contents for a YAML literal block scalar.
|
||||
|
||||
Blank lines are emitted truly empty rather than as whitespace: trailing
|
||||
spaces on an otherwise blank line are legal YAML but show up as diff noise
|
||||
in a committed artifact.
|
||||
"""
|
||||
pad = " " * indent
|
||||
return "\n".join(pad + line if line.strip() else "" for line in text.splitlines())
|
||||
|
||||
|
||||
def checksum(parts: list[str]) -> str:
|
||||
"""Stable content hash of everything embedded, stamped as a label.
|
||||
|
||||
A mounted ConfigMap updates in place without restarting anything, so without
|
||||
a visible change nothing signals that the pod is serving stale content.
|
||||
"""
|
||||
return str(zlib.crc32("".join(parts).encode()) & 0xFFFFFFFF)
|
||||
|
||||
|
||||
def build(namespace: str) -> str:
|
||||
contents = {}
|
||||
for key, path in EMBEDDED.items():
|
||||
if not path.exists():
|
||||
sys.exit(f"missing input: {path}")
|
||||
contents[key] = path.read_text()
|
||||
|
||||
# Fail loudly here rather than shipping an artifact that renders an error.
|
||||
try:
|
||||
json.loads(contents["bundle.json"])
|
||||
except json.JSONDecodeError as exc:
|
||||
sys.exit(f"bundle.json is not valid JSON: {exc}")
|
||||
|
||||
app = (UI / "k8s.yaml").read_text()
|
||||
app = app.replace("__RIG_NAME__", namespace)
|
||||
|
||||
data = "\n".join(
|
||||
f" {key}: |\n{block(text, 4)}" for key, text in sorted(contents.items())
|
||||
)
|
||||
|
||||
return f"""# GENERATED by ctrl/manifest.py — do not edit.
|
||||
# Regenerate with: make manifest
|
||||
#
|
||||
# Self-contained: applies as-is to any cluster, local kind or external.
|
||||
# kubectl apply -f this-file.yaml
|
||||
#
|
||||
# Namespace carries the identity, so several rigs coexist in one cluster.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: {namespace}
|
||||
labels:
|
||||
rig.bundle/name: {namespace}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: rig-ui
|
||||
namespace: {namespace}
|
||||
labels:
|
||||
rig.bundle/checksum: "{checksum(list(contents.values()))}"
|
||||
data:
|
||||
{data}
|
||||
---
|
||||
{_namespaced(app, namespace)}
|
||||
"""
|
||||
|
||||
|
||||
def _namespaced(doc: str, namespace: str) -> str:
|
||||
"""Add `namespace:` to each resource so the artifact applies without -n.
|
||||
|
||||
rig-ui/k8s.yaml omits it on purpose — applied by hand it should land in
|
||||
whatever namespace you choose. Pinning it belongs to the generated artifact,
|
||||
which has to be self-contained.
|
||||
"""
|
||||
return re.sub(
|
||||
r"^(metadata:\n(?:[ \t]+.*\n)*?)([ \t]+)(name: rig-ui)$",
|
||||
lambda m: f"{m.group(1)}{m.group(2)}{m.group(3)}\n{m.group(2)}namespace: {namespace}",
|
||||
doc.strip(),
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
target = sys.argv[1] if len(sys.argv) > 1 else slug(ROOT.name)
|
||||
sys.stdout.write(build(target))
|
||||
Reference in New Issue
Block a user