diff --git a/rig/.gitattributes b/rig/.gitattributes new file mode 100644 index 0000000..adfe28e --- /dev/null +++ b/rig/.gitattributes @@ -0,0 +1,22 @@ +# Line endings are normalised to LF in the repository and on checkout, on every +# platform. Without this, a checkout on Windows/WSL rewrites files to CRLF and +# every one of them shows up as modified without anyone having touched it. +# +# For the scripts it is not cosmetic: a shell script with CRLF fails on Linux +# with `bad interpreter: /usr/bin/env bash^M`, which reads as a broken installer +# rather than a line-ending problem — the worst possible first impression on a +# machine where nothing has been proven yet. +* text=auto eol=lf + +*.sh text eol=lf +*.py text eol=lf +*.env text eol=lf +*.yaml text eol=lf +*.yml text eol=lf + +# Never touch binaries. +*.png binary +*.jpg binary +*.zip binary +*.tar binary +*.gz binary diff --git a/rig/.gitignore b/rig/.gitignore new file mode 100644 index 0000000..563c914 --- /dev/null +++ b/rig/.gitignore @@ -0,0 +1,20 @@ +# def/ — the "default" scratch bucket: always gitignored, never versioned +def + +# local env (commit the .env.example, never the .env) +.env +.env.local +ctrl/.env + +# generated: the .dot is a build artifact rendered from arch/*.json, never hand-edited. +# The .svg IS committed — onboarding material should render in a repo browser. +arch/*.dot +ctrl/Tiltfile.gen + +# binaries pulled by `make deps-bundle` for the air-gapped wizard image +vendor + +# Client rigs are NOT ignored here. A copy is a SIBLING of this directory +# (spr/acme-rig), so a rule in this file cannot see it — the rules live in +# spr/.gitignore, anchored at spr's root, where `*-rig/` matches the siblings and +# `!rig/sample-rig/` keeps the committed stand-in. diff --git a/rig/BOOTSTRAP.md b/rig/BOOTSTRAP.md new file mode 100644 index 0000000..3a96251 --- /dev/null +++ b/rig/BOOTSTRAP.md @@ -0,0 +1,278 @@ +# From a machine with nothing on it to a project you can work in + +The README says the prerequisite is Docker and nothing else. This is what that +actually looks like end to end: a bare Linux box, and a new project running under +Tilt at the end of it. + +rig lives inside soleprint, at `spr/rig` — it is soleprint's cluster half, and +a client copy is a sibling (`spr/acme-rig`). Paths below are relative to +soleprint's checkout. + +It spans three repos because the work does. **rig** prepares the machine — the +pinned toolchain, the cluster, the port arithmetic. **all** owns the shape a +project takes, in `all/projects/templates/conventions.md` and the `broad` +scaffold beside it. **ppl** owns everything after local, and is where this +document stops. + +Read it once before running anything. Three of the steps below need root and one +needs a logout, so knowing about them in advance is cheaper than meeting them +halfway through. + + +## Docker, and the two sysctls Tilt depends on + +rig installs a toolchain; it does not install Docker. That line is not modesty — +Docker is a daemon, a group membership and usually a logout, and a script that +did it would have to be trusted with root on a machine it knows nothing about. + +```bash +sudo apt-get install -y docker.io && sudo usermod -aG docker "$USER" +``` + +Then log out and back in, and check `docker info` answers. Until it does, nothing +below works and everything below reports the same failure. + +While you have root, raise the inotify limits: + +```bash +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 +``` + +kind and Tilt both watch large trees, and WSL ships 8192 watches and 128 +instances — far too low. The failure mode is the reason this is here at step +zero rather than mentioned later: Tilt does not error, it simply stops noticing +that files changed, and you lose an afternoon to a hot reload that silently +isn't. + + +## Read the docs before installing anything + +```bash +cd spr/rig +make docs +``` + +`ctrl/docs.sh` runs a throwaway `nginx:alpine` over a read-only bind mount of +`docs/` and prints the URL. That is deliberate: the docs are the instructions for +building everything else, so they cannot live in the cluster and cannot need +`python3 -m http.server` either — a minimal Debian has no python. What it has, +by definition, is Docker. + +The port is this environment's `HTTP_PORT + 4`. Nothing is installed and nothing +persists; ctrl-c ends it. + + +## Ask what is wrong with this machine + +```bash +make station +cp ctrl/.env.example ctrl/.env +``` + +`station.sh` reports and instructs, and fixes nothing. It runs bare rather than +in a container because host detection only ever reads `/proc` and `/etc` — no +dependency beyond coreutils. + +Read the whole output, but the `ports` block is the one to read carefully. Every +port rig binds derives from this directory's name, so the answer is specific to +this copy, and a clash here surfaces as an opaque `failed to bind host port` in +the middle of cluster creation if you skip it. + +Copy the `.env` even though station only warns about it. It is gitignored, it is +where a machine-local override goes, and `ports.sh persist` expects it to exist. + + +## Install the toolchain — through the container + +This is the step where "nothing installed" stops being rhetorical. + +`make deps` runs `ctrl/wizard.sh install` directly on the host, and the wizard +fetches with `curl`. A stock `debian:trixie-slim` has no curl — detection runs +fine, then the first download dies with `curl: command not found` and an exit +code of 127. That is the bootstrap paradox `ctrl/Dockerfile.wizard` exists +to kill — the wizard carries its own toolchain so the host needs only Docker — +but building the image and running it are two different things, and only the +build has a Makefile target today. **On a genuinely bare machine, run it by +hand:** + +```bash +make wizard # builds rig-wizard:wizard +mkdir -p ~/.local/bin +docker run --rm \ + -v /:/host:ro \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v "$HOME/.local/bin:/out/bin" \ + -e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" \ + rig-wizard:wizard install dev +``` + +The image name follows the directory, like everything else here: in `spr/rig` +it is `rig-wizard`, in a copy called `spr/acme-rig` it is `acme-rig-wizard`. The +tag is `wizard` (or `full`, below), not `latest`. + +None of the four arguments are guessable, so: + +- **`/:/host:ro`** — the wizard reads the *host's* `/etc/os-release` and + `/etc/wsl.conf`, not the container's. `HOST_ROOT=/host` is already baked into + the image; this is what it points at. Read-only, and it is the only reason + detection inside a container tells you anything about the machine. +- **the docker socket** — how detection reaches the daemon it is reporting on, + and how it counts kind clusters already running. +- **`/out/bin`** — the image's `OUT_BIN`. Whatever you mount here is where the + four binaries land. +- **`HOST_UID` / `HOST_GID`** — the wizard runs as root so it can reach that + socket, which means everything it writes into a mounted volume is root-owned + and useless to you. These drive the `chown` back. Omit them and the install + looks like it worked. + +`dev` is kubectl, jq, kind and tilt. `core` is kubectl and jq alone — no cluster +tooling — which is the right answer on a managed or corporate-issued machine and +is why the split exists. + +Then put them on PATH, which the wizard will remind you about because it cannot +edit your shell for you: + +```bash +export PATH="$HOME/.local/bin:$PATH" # and add the same line to ~/.bashrc +``` + +If something else on this machine already provides `kubectl`, the wizard says so +by name rather than shadowing it quietly. `OUT_BIN=$PWD/def/bin` installs +somewhere private instead. + +**Two variants worth knowing before you need them.** `make wizard full` bakes +every pinned binary into the image at build time (`DEPS_SOURCE=baked`), so +`docker save` gives you the entire installer as one file to carry into an +air-gapped network. And `DEPS_SOURCE=artifactory` with `DEPS_ARTIFACTORY_URL` +pulls from a generic internal repo, which is usually the only thing a locked-down +client allows. + +From here on this machine has curl, so **`make deps` is the short form** for +every later run and every later copy of this directory. The container path is +the first-time path. + + +## Prove the machine before blaming the project + +```bash +make setup +make cluster up +kubectl get nodes +``` + +`make setup` re-runs every check as a group. It is idempotent and it deliberately +does not abort on the first failure — a setup script that dies at step two hides +the fact that steps four and five were also going to fail. Run now, it should be +`ok` and `done` all the way down, and that is the point: it is the scoreboard, +not the installer. + +`make cluster up` builds the default `minimal` profile — one node, no addons, +boots fast. You do not need it to develop anything, but you do want to know that +kind, the kubeconfig context and the derived port block work *before* a new +project has any problems of its own to confuse them with. `make cluster down` +when you are done looking. + +Before starting a second cluster, and it will not be long: + +```bash +make cluster list +``` + +Available memory, per-cluster usage and each cluster's port block. On a 16 GiB +box four single-node clusters are comfortable and six push into swap, so this is +worth reading before rather than after. `make cluster free ` stops +clusters without deleting them; `docker start` brings them back untouched. + + +## Scaffold the project + +The canonical layout is [`all/projects/templates/conventions.md`](../all/projects/templates/conventions.md). +Read it — it is short, opinionated, and exists precisely so nobody +reverse-engineers a layout from whichever repo they happened to open. What +follows is only the mechanical part. + +```bash +SLUG= # short, lowercase, no separators +cp -r ~/wdir/all/projects/templates/broad ~/wdir/"$SLUG" +cd ~/wdir/"$SLUG" +grep -rl '' ctrl | xargs sed -i "s//$SLUG/g" +cp ctrl/k8s/.env.example ctrl/k8s/.env +git init && git add -A && git commit -m "scaffold $SLUG from broad" +``` + +`` is the only placeholder and it lives only under `ctrl/` — cluster name, +namespace, ConfigMap name, and the `NAME=` in `kind-up.sh` / `kind-down.sh`. One +sed does all of it. + +The slug is the folder name, lowercase and short — `mpr`, `unt`, `nvi`. The +cluster takes that name and the context becomes `kind-`, derived by the +scaffold's Makefile from the directory, so there is nothing to edit for either. + +**Pick the Tilt port deliberately.** `ctrl/k8s/.env.example` ships a value that +is already in use, so copying it unchanged puts two projects on one port: + +```bash +grep -h '^TILT_PORT=' ~/wdir/*/ctrl/k8s/.env 2>/dev/null | sort +``` + +Choose a free one in `10300–10399` — the range ALL reserves in +`projects/index.json` under `policy` — avoiding `10350`, which is Tilt's own +default. Currently taken: `nvi` 10330, `unt` 10340, `mpr` 10360, `mlv` 10370, +`eth` 10380, `lng` 10390. This is the Tilt *web UI* port, not a service port; +each project owns its own service ports separately. The scaffold ships it blank +on purpose, so there is nothing to collide with until you choose. + +The scaffold's `ctrl/k8s/` is the same shape as every other project here, and it +builds as shipped: + +``` +kind-config.yaml one node; gateway NodePort 30080 -> hostPort 8080 +base/ namespace, configmap, app (Deployment + Service) +overlays/dev/ promotes the app Service to NodePort 30080 +``` + +Check it before `kind` spends minutes on anything — this renders the whole tree +without a cluster and catches a broken patch immediately: + +```bash +kubectl kustomize ctrl/k8s/overlays/dev +``` + +The workload is an nginx placeholder so a fresh copy reaches something that +answers; replace it. Keep `30080` in step between the overlay patch and +`kind-config.yaml`'s `containerPort` — the hostPort is this project's to pick. +Reachability is a plain kind port mapping: no ingress controller and no MetalLB. +Caddy maps `.local.ar` onto the host port (`~/wdir/ppl/local/Caddyfile`), +with `*.local.ar` resolving to 127.0.0.1 through dnsmasq. That is the whole chain. + +**The one file the scaffold still does not ship is `ctrl/Tiltfile`** — `make +tilt-up` runs `cd ctrl && tilt up`, and there is nothing to run until you write +one. Copy it from a live project; `unt` and `nvi` are closest to the plain shape. + + +## Run it + +```bash +make kind-up # idempotent create, then selects the context +make tilt-up # context + your assigned port +``` + +`tilt-up` passes `--context kind-` every time, which is the point of going +through `make` at all: tilt cannot deploy into whichever cluster you last looked +at. + +`make tilt-down` and `make kind-down` close the loop, and `make kind-reset` is +delete-and-recreate for when a cluster wedges. + + +## Register it + +The project exists; now it is findable. Add an entry to +`~/wdir/all/projects/index.json` and write its `projects/.md` beside the +others. Structured fields in the index, prose in the markdown. + +Putting it on the CI server and deploying it is `ppl`'s half, and it starts at +`~/wdir/ppl/ctrl/init-repo.sh` — gitea remote, then Woodpecker. That is a +different document. diff --git a/rig/Makefile b/rig/Makefile new file mode 100644 index 0000000..f33c834 --- /dev/null +++ b/rig/Makefile @@ -0,0 +1,123 @@ +# Thin control Makefile — one target per ctrl/ script, and the subcommand is an +# argument rather than a second target: `make cluster down`, not `make cluster-down`. +# +# The logic lives in the scripts, never here. Each target maps to exactly one +# bash file, and that file holds the variants: +# +# make cluster up -> ctrl/cluster.sh up +# make newbox destroy -> ctrl/newbox.sh destroy +# +# Config layers, weakest first: ctrl/versions.env (pinned toolchain) < +# ctrl/env.d/.env (cluster shape) < ctrl/.env (local, gitignored) < +# the environment. So `make cluster up PROFILE=client` beats everything. +# +# Start with: make setup (then: make cluster up && make docs) + +# Identity follows the FOLDER NAME, so this directory can be copied elsewhere, +# renamed, and run as a separate environment with no edits. ctrl/.env overrides +# it when you want a name that differs from the directory. +SLUG := $(shell echo '$(notdir $(CURDIR))' | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-' | sed 's/^-*//; s/-*$$//') +CLUSTER := $(or $(shell sed -n 's/^CLUSTER=//p' ctrl/.env 2>/dev/null),$(SLUG)) +KCTX := --context kind-$(CLUSTER) +TILT_PORT := $(shell sed -n 's/^TILT_PORT=//p' ctrl/.env 2>/dev/null) +WIZARD := $(SLUG)-wizard + +# Words after the target become the script's subcommand. Make would otherwise +# treat them as goals of their own, so each gets a no-op rule. +ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) +ifneq ($(ARGS),) +$(eval $(ARGS):;@:) +# ...and as PHONY, because some of those words name real directories. `cfg`, +# `ctrl`, `docs`, `gen` and `init` all exist at this level, and make considers a +# target that is an existing directory already built — so `make build ctrl` ran +# the build and then printed "make: 'ctrl' is up to date". The empty rule above +# is not enough on its own; only .PHONY stops make consulting the filesystem. +.PHONY: $(ARGS) +endif + +.PHONY: help setup station deps wizard cluster registry addons ports \ + newbox dockerhost docs tilt \ + kind-up kind-down kind-reset tilt-up tilt-down + +help: ## list targets + @grep -hE '^[a-z][a-z-]*:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16 + +# ── setup ────────────────────────────────────────────────────────────────── + +setup: ## prepare this machine [core] [--share-docker] [--cluster] + bash ctrl/setup.sh $(ARGS) + +station: ## is this workstation ready? reports, never fixes + bash ctrl/station.sh + +deps: ## install the toolchain [core|dev] (default dev) + bash ctrl/wizard.sh install $(or $(ARGS),dev) + +wizard: ## build the installer image [full] + docker build -f ctrl/Dockerfile.wizard \ + --target $(if $(filter full,$(ARGS)),wizard-full,wizard) \ + -t $(WIZARD):$(if $(filter full,$(ARGS)),full,wizard) . + +# ── cluster ──────────────────────────────────────────────────────────────── + +cluster: ## this env + the machine [up|down|reset|list|free] + bash ctrl/cluster.sh $(or $(ARGS),up) + +registry: ## registry wiring [up|down|status] (default status) + bash ctrl/registry.sh $(or $(ARGS),status) + +addons: ## profile addons [install|list] (default list) + bash ctrl/addons.sh $(or $(ARGS),list) + +ports: ## this environment's port block [show|persist] + bash ctrl/ports.sh $(or $(ARGS),show) + +# ── host ─────────────────────────────────────────────────────────────────── + +newbox: ## throwaway environment [create|status|shell|destroy] + bash ctrl/newbox.sh $(or $(ARGS),status) + +dockerhost: ## share Docker between distros [status|share|unshare] + $(if $(filter share unshare,$(ARGS)),sudo ,)bash ctrl/dockerhost.sh $(or $(ARGS),status) + +# ── docs + dev loop ──────────────────────────────────────────────────────── + +docs: ## documentation [serve|graphs] (default serve) + bash ctrl/docs.sh $(or $(ARGS),serve) + +# --port is only passed when TILT_PORT is actually set. It comes from ctrl/.env, +# which does NOT carry it by default — ports are derived at runtime in +# lib/config.sh unless `make ports persist` has written them. Without the guard +# tilt receives a bare `--port` with no value and fails on the flag rather than +# on anything real. `make ports show` prints the derived block. +tilt: ## dev loop [up|down] (default up) + cd ctrl && tilt $(or $(ARGS),up) $(KCTX) $(if $(filter down,$(ARGS)),,$(if $(TILT_PORT),--port $(TILT_PORT))) + +# ── the shape every other project uses ───────────────────────────────────── +# Aliases, not a second implementation: each one calls the same script the +# canonical target does. +# +# The header above argues for `make cluster down` over `make cluster-down`, and +# that still holds *within* this file. But rig is one repo among several on the +# same machine, and every other one answers to kind-up / tilt-up. Muscle memory +# spanning six projects beats internal tidiness in one, so both spellings work. +# +# `cluster list` and `cluster free` have no hyphenated twin on purpose — they +# are rig's own, with nothing to be consistent with. + +kind-up: ## alias for `cluster up` + bash ctrl/cluster.sh up + +kind-down: ## alias for `cluster down` + bash ctrl/cluster.sh down + +kind-reset: ## alias for `cluster reset` + bash ctrl/cluster.sh reset + +# These two match the other projects' spelling, but rig has no Tiltfile — there +# is nothing to run yet, and they fail the same way `make tilt` does. +tilt-up: ## alias for `tilt up` (rig has no Tiltfile yet) + cd ctrl && tilt up $(KCTX) $(if $(TILT_PORT),--port $(TILT_PORT)) + +tilt-down: ## alias for `tilt down` (rig has no Tiltfile yet) + cd ctrl && tilt down $(KCTX) diff --git a/rig/README.md b/rig/README.md new file mode 100644 index 0000000..1c0ed0e --- /dev/null +++ b/rig/README.md @@ -0,0 +1,117 @@ +# rig + +A runnable local model of a large, regulated estate — legacy and new side by +side. Its job is onboarding and exploration, not a production replica: most +services are deliberately mocked, because what has to be faithful is the +topology, not the workloads. + +## Prerequisite + +**Docker.** Nothing else — no curl, no jq, no python, no apt repositories. + +## Read the docs first + +```bash +make docs # serves on localhost, prints the URL +``` + +They run before anything is installed, which matters because they are the +instructions for everything else. No cluster and no toolchain required. + +## Then + +```bash +make station # report host and config problems; changes nothing +make deps # install the toolchain (add `core` on a managed machine) +make cluster up # build the cluster for the active profile +``` + +`make help` lists every target. + +On a machine where Docker really is the only thing installed, `make deps` has +nothing to download with — see [BOOTSTRAP.md](BOOTSTRAP.md), which runs the +toolchain through the wizard container and carries on to scaffolding and running +a new project. + +## One directory is one environment + +Copy this directory, rename it, run it. Cluster name, kubectl context, image +tags and the host port block all derive from the directory name, so copies never +collide and neither one's teardown can touch the other. + +rig lives inside soleprint, at `spr/rig` — it is soleprint's cluster half, and a +copy is a **sibling**: `spr/acme-rig`. That is why the ignore rules for client +rigs sit in `spr/.gitignore` rather than here; a rule in this directory cannot +see a directory beside it. + +## Profiles + +A profile is the shape of the cluster: how many nodes, which addons, whether the +apiserver audits. They live in `ctrl/env.d/`, and the active one is `PROFILE`. + +| Profile | For | +| --- | --- | +| `minimal` | the default. One node, no addons, boots fast. | +| `client` | the regulated-estate shape — multi-node, audit on, registry mirror. | +| `offline` | air-gapped: everything from a preloaded local registry. | +| `data` | the dependency containers a soleprint room asks for. | + +```bash +PROFILE=data make cluster up +PROFILE=data make addons install +make addons # what the active profile wants, and what exists +``` + +A profile names a **cluster shape** — a file in `ctrl/k8s/` — rather than +restating node count and audit as variables: + +| shape | nodes | audit | used by | +| --- | --- | --- | --- | +| `kind-config.yaml.tpl` | 1 | off | `minimal`, `data` | +| `kind-config.audit.yaml.tpl` | 1 | on | `offline` | +| `kind-config.client.yaml.tpl` | 3 | on | `client` | + +Both numbers are read back out of the chosen file, so the YAML is the only place +that decides and there is nothing to drift. The layout under `ctrl/k8s/` is the +same as every other project here — a kind config, a kustomize `base/`, an +`overlays/dev/` — see [`ctrl/k8s/README.md`](ctrl/k8s/README.md). + +## Addons + +Each addon is its own idempotent script in `ctrl/addons/`, and a profile names +the ones it wants in `ADDONS`. Adding one is adding a file — there is no +dispatcher to edit. + +**There is no ingress controller, deliberately.** They pin a narrow window of +Kubernetes versions, so depending on one would constrain which k8s a rig can be +built with — and running a trailing-edge control plane to model a legacy estate +is the whole point. Services are reached through MetalLB and +`type: LoadBalancer`, which carries no such constraint and is also what a real +cluster does. + +| Addon | Does | +| --- | --- | +| `metallb` | gives `type: LoadBalancer` an address it can actually reach | +| `cert-manager` | a local CA, so TLS works offline | +| `metrics-server` | makes `kubectl top` work on kind | +| `postgres` | database, in the `data` namespace | +| `redis` | cache and broker | +| `airflow` | scheduled pipelines; needs postgres and redis | + +The last three are the cluster half of **soleprint's cabinets**. A room declares +what it needs once, in `cfg//data/cabinets.json`; soleprint's `build.py` +composes those services into `docker-compose.yml` for a laptop, and these +install the same ones here. The names match on purpose — each cabinet carries a +`rig_addon` field pointing at `ctrl/addons/.sh`. + +Plain manifests rather than helm charts, like every other addon: a chart repo is +a network dependency, and the `offline` profile exists precisely so there is a +path with none. Images are pinned in `ctrl/versions.env` and can be preloaded. + +Passwords are generated on first install and kept across re-runs, so re-running +an addon never rotates a credential out from under something already connected: + +```bash +kubectl -n data get secret postgres -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d +kubectl -n data port-forward svc/airflow 8080:8080 +``` diff --git a/rig/ctrl/.env.example b/rig/ctrl/.env.example new file mode 100644 index 0000000..5f07c10 --- /dev/null +++ b/rig/ctrl/.env.example @@ -0,0 +1,50 @@ +# Machine-local config. Copy to ctrl/.env (gitignored) and edit. +# Cluster SHAPE lives in ctrl/env.d/.env — not here. +# The architecture MODEL lives in arch/.json — not here either. + +# Which profile in ctrl/env.d/ to build. minimal | client | offline +PROFILE=minimal + +# Cluster name; the kubectl context becomes kind-. +# 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.) diff --git a/rig/ctrl/Dockerfile.wizard b/rig/ctrl/Dockerfile.wizard new file mode 100644 index 0000000..c2209e7 --- /dev/null +++ b/rig/ctrl/Dockerfile.wizard @@ -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 -wizard . +# docker build -f ctrl/Dockerfile.wizard --target wizard-full -t -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 diff --git a/rig/ctrl/addons.sh b/rig/ctrl/addons.sh new file mode 100755 index 0000000..2a794a1 --- /dev/null +++ b/rig/ctrl/addons.sh @@ -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 diff --git a/rig/ctrl/addons/airflow.sh b/rig/ctrl/addons/airflow.sh new file mode 100755 index 0000000..eaed535 --- /dev/null +++ b/rig/ctrl/addons/airflow.sh @@ -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 </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" diff --git a/rig/ctrl/addons/metallb.sh b/rig/ctrl/addons/metallb.sh new file mode 100755 index 0000000..b452da9 --- /dev/null +++ b/rig/ctrl/addons/metallb.sh @@ -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 +# 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 <&2 +$K get pods -n metallb-system >&2 +exit 1 diff --git a/rig/ctrl/addons/metrics-server.sh b/rig/ctrl/addons/metrics-server.sh new file mode 100755 index 0000000..25abef9 --- /dev/null +++ b/rig/ctrl/addons/metrics-server.sh @@ -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 diff --git a/rig/ctrl/addons/postgres.sh b/rig/ctrl/addons/postgres.sh new file mode 100755 index 0000000..c5949da --- /dev/null +++ b/rig/ctrl/addons/postgres.sh @@ -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//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 </dev/null 2>&1 || $K create namespace "$NS" + +echo " applying manifests" +$K apply -n "$NS" -f - >/dev/null </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 diff --git a/rig/ctrl/dockerhost.sh b/rig/ctrl/dockerhost.sh new file mode 100755 index 0000000..5550815 --- /dev/null +++ b/rig/ctrl/dockerhost.sh @@ -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" <&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" < "$OWNER_FILE" <&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 diff --git a/rig/ctrl/docs.sh b/rig/ctrl/docs.sh new file mode 100755 index 0000000..418c38f --- /dev/null +++ b/rig/ctrl/docs.sh @@ -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 diff --git a/rig/ctrl/env.d/client.env b/rig/ctrl/env.d/client.env new file mode 100644 index 0000000..1297ec8 --- /dev/null +++ b/rig/ctrl/env.d/client.env @@ -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 diff --git a/rig/ctrl/env.d/data.env b/rig/ctrl/env.d/data.env new file mode 100644 index 0000000..5b1f078 --- /dev/null +++ b/rig/ctrl/env.d/data.env @@ -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//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//cabinet.json carries a `rig_addon` field +# pointing at ctrl/addons/.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 diff --git a/rig/ctrl/env.d/minimal.env b/rig/ctrl/env.d/minimal.env new file mode 100644 index 0000000..1ee8073 --- /dev/null +++ b/rig/ctrl/env.d/minimal.env @@ -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/. 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. diff --git a/rig/ctrl/env.d/offline.env b/rig/ctrl/env.d/offline.env new file mode 100644 index 0000000..71055a1 --- /dev/null +++ b/rig/ctrl/env.d/offline.env @@ -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 diff --git a/rig/ctrl/hosts.tmpl b/rig/ctrl/hosts.tmpl new file mode 100644 index 0000000..41b44e7 --- /dev/null +++ b/rig/ctrl/hosts.tmpl @@ -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: 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 diff --git a/rig/ctrl/k8s/README.md b/rig/ctrl/k8s/README.md new file mode 100644 index 0000000..9ccaa52 --- /dev/null +++ b/rig/ctrl/k8s/README.md @@ -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/.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. diff --git a/rig/ctrl/k8s/audit-policy.yaml b/rig/ctrl/k8s/audit-policy.yaml new file mode 100644 index 0000000..edb7d58 --- /dev/null +++ b/rig/ctrl/k8s/audit-policy.yaml @@ -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 -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"] diff --git a/rig/ctrl/k8s/base/example-mock.yaml b/rig/ctrl/k8s/base/example-mock.yaml new file mode 100644 index 0000000..fa1c090 --- /dev/null +++ b/rig/ctrl/k8s/base/example-mock.yaml @@ -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 diff --git a/rig/ctrl/k8s/base/example-remote.yaml b/rig/ctrl/k8s/base/example-remote.yaml new file mode 100644 index 0000000..e5ff408 --- /dev/null +++ b/rig/ctrl/k8s/base/example-remote.yaml @@ -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..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 diff --git a/rig/ctrl/k8s/base/kustomization.yaml b/rig/ctrl/k8s/base/kustomization.yaml new file mode 100644 index 0000000..7e5b432 --- /dev/null +++ b/rig/ctrl/k8s/base/kustomization.yaml @@ -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 diff --git a/rig/ctrl/k8s/base/namespace.yaml b/rig/ctrl/k8s/base/namespace.yaml new file mode 100644 index 0000000..c7be6d1 --- /dev/null +++ b/rig/ctrl/k8s/base/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: rig diff --git a/rig/ctrl/k8s/kind-config.audit.yaml.tpl b/rig/ctrl/k8s/kind-config.audit.yaml.tpl new file mode 100644 index 0000000..94887da --- /dev/null +++ b/rig/ctrl/k8s/kind-config.audit.yaml.tpl @@ -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 diff --git a/rig/ctrl/k8s/kind-config.client.yaml.tpl b/rig/ctrl/k8s/kind-config.client.yaml.tpl new file mode 100644 index 0000000..060ef8d --- /dev/null +++ b/rig/ctrl/k8s/kind-config.client.yaml.tpl @@ -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} diff --git a/rig/ctrl/k8s/kind-config.yaml.tpl b/rig/ctrl/k8s/kind-config.yaml.tpl new file mode 100644 index 0000000..e91d313 --- /dev/null +++ b/rig/ctrl/k8s/kind-config.yaml.tpl @@ -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 diff --git a/rig/ctrl/k8s/overlays/dev/kustomization.yaml b/rig/ctrl/k8s/overlays/dev/kustomization.yaml new file mode 100644 index 0000000..74966c8 --- /dev/null +++ b/rig/ctrl/k8s/overlays/dev/kustomization.yaml @@ -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 diff --git a/rig/ctrl/lib/config.sh b/rig/ctrl/lib/config.sh new file mode 100644 index 0000000..3911763 --- /dev/null +++ b/rig/ctrl/lib/config.sh @@ -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/ 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 +} diff --git a/rig/ctrl/newbox.sh b/rig/ctrl/newbox.sh new file mode 100755 index 0000000..b634839 --- /dev/null +++ b/rig/ctrl/newbox.sh @@ -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 </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 < /etc/sysctl.d/99-rig.conf </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 diff --git a/rig/ctrl/ports.sh b/rig/ctrl/ports.sh new file mode 100755 index 0000000..6dcf5db --- /dev/null +++ b/rig/ctrl/ports.sh @@ -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 diff --git a/rig/ctrl/registry.sh b/rig/ctrl/registry.sh new file mode 100755 index 0000000..d5d85b7 --- /dev/null +++ b/rig/ctrl/registry.sh @@ -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/, 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//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" </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 - </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 diff --git a/rig/ctrl/setup.sh b/rig/ctrl/setup.sh new file mode 100755 index 0000000..3436e15 --- /dev/null +++ b/rig/ctrl/setup.sh @@ -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" diff --git a/rig/ctrl/station.sh b/rig/ctrl/station.sh new file mode 100755 index 0000000..c377198 --- /dev/null +++ b/rig/ctrl/station.sh @@ -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 diff --git a/rig/ctrl/versions.env b/rig/ctrl/versions.env new file mode 100644 index 0000000..bf047ab --- /dev/null +++ b/rig/ctrl/versions.env @@ -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/.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 diff --git a/rig/ctrl/wizard.sh b/rig/ctrl/wizard.sh new file mode 100755 index 0000000..532e310 --- /dev/null +++ b/rig/ctrl/wizard.sh @@ -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 — 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 +# 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 `, 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 diff --git a/rig/docs/graphs/01-install.dot b/rig/docs/graphs/01-install.dot new file mode 100644 index 0000000..baa76f5 --- /dev/null +++ b/rig/docs/graphs/01-install.dot @@ -0,0 +1,47 @@ +digraph rig_install { + rankdir=LR + bgcolor="#0a0e17" + fontname="Helvetica" + node [fontname="Helvetica" fontsize=11 style=filled color="#1e2a4a" fontcolor="#e8eaf0" shape=box] + edge [fontname="Helvetica" fontsize=9 fontcolor="#8892a8" color="#4a5568"] + + label="Installation — the only host prerequisite is Docker" + labelloc=t + fontsize=16 + fontcolor="#0066ff" + + subgraph cluster_host { + label="Your machine" + style=dashed + color="#1e2a4a" + fontcolor="#8892a8" + + docker [label="Docker\n(the one prerequisite)" fillcolor="#1a1a3a" fontcolor="#0066ff" shape=octagon] + bin [label="~/.local/bin\nkind · kubectl · tilt\njq" fillcolor="#121829" shape=cylinder] + } + + subgraph cluster_wizard { + label="Installer container (transient)" + style=dashed + color="#1e2a4a" + fontcolor="#8892a8" + + wizard [label="wizard\ncurl · jq · python · graphviz" fillcolor="#121829"] + detect [label="detect host\nWSL · memory · inotify · docker" fillcolor="#121829"] + fetch [label="fetch + verify\nSHA256, pinned versions" fillcolor="#121829"] + } + + upstream [label="upstream\nreleases / corporate mirror" fillcolor="#1a3a1a" fontcolor="#00c853" shape=octagon] + report [label="report what it\nCANNOT do" fillcolor="#3a1a1a" fontcolor="#ffc107"] + + docker -> wizard [label="docker run"] + wizard -> detect + detect -> fetch + fetch -> upstream [label="pinned + checksummed" color="#00c853"] + fetch -> bin [label="install"] + detect -> report [style=dashed label="sudo / Windows-side steps" color="#ffc107"] + + // The container is gone after this; nothing depends on it at run time. + wizard -> gone [style=dotted label="exits"] + gone [label="(container discarded)" fillcolor="#0a0e17" fontcolor="#4a5568" color="#1e2a4a" style="filled,dashed"] +} diff --git a/rig/docs/graphs/01-install.svg b/rig/docs/graphs/01-install.svg new file mode 100644 index 0000000..21613c0 --- /dev/null +++ b/rig/docs/graphs/01-install.svg @@ -0,0 +1,128 @@ + + + + + + +rig_install + +Installation — the only host prerequisite is Docker + +cluster_host + +Your machine + + +cluster_wizard + +Installer container (transient) + + + +docker + +Docker +(the one prerequisite) + + + +wizard + +wizard +curl · jq · python · graphviz + + + +docker->wizard + + +docker run + + + +bin + + +~/.local/bin +kind · kubectl · tilt +jq + + + +detect + +detect host +WSL · memory · inotify · docker + + + +wizard->detect + + + + + +gone + +(container discarded) + + + +wizard->gone + + +exits + + + +fetch + +fetch + verify +SHA256, pinned versions + + + +detect->fetch + + + + + +report + +report what it +CANNOT do + + + +detect->report + + +sudo / Windows-side steps + + + +fetch->bin + + +install + + + +upstream + +upstream +releases / corporate mirror + + + +fetch->upstream + + +pinned + checksummed + + + diff --git a/rig/docs/graphs/02-environment.dot b/rig/docs/graphs/02-environment.dot new file mode 100644 index 0000000..6dda83b --- /dev/null +++ b/rig/docs/graphs/02-environment.dot @@ -0,0 +1,54 @@ +digraph rig_environment { + rankdir=TB + bgcolor="#0a0e17" + fontname="Helvetica" + node [fontname="Helvetica" fontsize=11 style=filled color="#1e2a4a" fontcolor="#e8eaf0" shape=box] + edge [fontname="Helvetica" fontsize=9 fontcolor="#8892a8" color="#4a5568"] + + label="One environment per directory — copies never collide" + labelloc=t + fontsize=16 + fontcolor="#0066ff" + + dirname [label="directory name\ne.g. acmebank/" fillcolor="#1f6feb" fontcolor="#ffffff" shape=octagon] + + subgraph cluster_derived { + label="Everything below is derived from it" + style=dashed + color="#1e2a4a" + fontcolor="#8892a8" + + cname [label="cluster name\nacmebank" fillcolor="#121829"] + ctx [label="kubectl context\nkind-acmebank" fillcolor="#121829"] + img [label="image tag\nacmebank-wizard" fillcolor="#121829"] + ports [label="port block\n21300–21309" fillcolor="#121829"] + reg [label="registry container\nacmebank-registry" fillcolor="#121829"] + } + + subgraph cluster_config { + label="Configuration — weakest first, later wins" + style=dashed + color="#1e2a4a" + fontcolor="#8892a8" + + versions [label="versions.env\npinned toolchain" fillcolor="#121829"] + profile [label="env.d/.env\nnodes · CNI · audit · addons" fillcolor="#121829"] + localenv [label="ctrl/.env\nsecrets, overrides" fillcolor="#121829"] + shell [label="the environment\nPROFILE=client make …" fillcolor="#1a3a1a" fontcolor="#00c853"] + } + + dirname -> cname + dirname -> ctx + dirname -> img + dirname -> ports + dirname -> reg + + versions -> profile [label="overridden by"] + profile -> localenv [label="overridden by"] + localenv -> shell [label="overridden by" color="#00c853"] + + cluster [label="kind cluster" fillcolor="#1a1a3a" fontcolor="#0066ff" shape=octagon] + cname -> cluster + ports -> cluster + shell -> cluster [style=dashed] +} diff --git a/rig/docs/graphs/02-environment.svg b/rig/docs/graphs/02-environment.svg new file mode 100644 index 0000000..9528093 --- /dev/null +++ b/rig/docs/graphs/02-environment.svg @@ -0,0 +1,169 @@ + + + + + + +rig_environment + +One environment per directory — copies never collide + +cluster_derived + +Everything below is derived from it + + +cluster_config + +Configuration — weakest first, later wins + + + +dirname + +directory name +e.g. acmebank/ + + + +cname + +cluster name +acmebank + + + +dirname->cname + + + + + +ctx + +kubectl context +kind-acmebank + + + +dirname->ctx + + + + + +img + +image tag +acmebank-wizard + + + +dirname->img + + + + + +ports + +port block +21300–21309 + + + +dirname->ports + + + + + +reg + +registry container +acmebank-registry + + + +dirname->reg + + + + + +cluster + +kind cluster + + + +cname->cluster + + + + + +ports->cluster + + + + + +versions + +versions.env +pinned toolchain + + + +profile + +env.d/<profile>.env +nodes · CNI · audit · addons + + + +versions->profile + + +overridden by + + + +localenv + +ctrl/.env +secrets, overrides + + + +profile->localenv + + +overridden by + + + +shell + +the environment +PROFILE=client make … + + + +localenv->shell + + +overridden by + + + +shell->cluster + + + + + diff --git a/rig/docs/graphs/03-architecture.dot b/rig/docs/graphs/03-architecture.dot new file mode 100644 index 0000000..ae9e595 --- /dev/null +++ b/rig/docs/graphs/03-architecture.dot @@ -0,0 +1,56 @@ +// TODO: PLACEHOLDER — replace with the real estate topology. +// +// This is where the extracted platform diagrams land. The shape below is +// illustrative only: it shows how a mocked dependency, a real service and a +// remote system are meant to sit together, not what the system actually is. +// +// The intended end state is that this file stops being hand-written and is +// generated from the running cluster, so the diagram becomes a report of what +// exists rather than a drawing of what was once intended. +digraph estate { + rankdir=LR + bgcolor="#0a0e17" + fontname="Helvetica" + node [fontname="Helvetica" fontsize=11 style=filled color="#1e2a4a" fontcolor="#e8eaf0" shape=box] + edge [fontname="Helvetica" fontsize=9 fontcolor="#8892a8" color="#4a5568"] + + label="Estate topology — PLACEHOLDER" + labelloc=t + fontsize=16 + fontcolor="#ffc107" + + subgraph cluster_new { + label="New" + style=dashed + color="#1e2a4a" + fontcolor="#8892a8" + + api [label="service under work\n(real: built and hot-reloaded)" fillcolor="#1a3a1a" fontcolor="#00c853"] + } + + subgraph cluster_core { + label="Core (mocked)" + style=dashed + color="#1e2a4a" + fontcolor="#8892a8" + + svc_a [label="upstream service\n(mock: canned responses)" fillcolor="#121829"] + db [label="datastore\n(mock)" fillcolor="#121829" shape=cylinder] + } + + subgraph cluster_legacy { + label="Legacy estate (mocked)" + style=dashed + color="#1e2a4a" + fontcolor="#8892a8" + + batch [label="batch drop\n(mock: writes files on a timer)" fillcolor="#121829"] + } + + remote [label="external system\n(remote: ExternalName,\nreachable only from a VDI)" fillcolor="#3a1a1a" fontcolor="#ffc107" shape=octagon] + + api -> svc_a [label="HTTP"] + api -> db [label="query"] + api -> batch [label="file handoff" style=dashed] + api -> remote [label="only when reachable" style=dashed color="#ffc107"] +} diff --git a/rig/docs/graphs/03-architecture.svg b/rig/docs/graphs/03-architecture.svg new file mode 100644 index 0000000..f02fbda --- /dev/null +++ b/rig/docs/graphs/03-architecture.svg @@ -0,0 +1,94 @@ + + + + + + +estate + +Estate topology — PLACEHOLDER + +cluster_new + +New + + +cluster_core + +Core (mocked) + + +cluster_legacy + +Legacy estate (mocked) + + + +api + +service under work +(real: built and hot-reloaded) + + + +svc_a + +upstream service +(mock: canned responses) + + + +api->svc_a + + +HTTP + + + +db + + +datastore +(mock) + + + +api->db + + +query + + + +batch + +batch drop +(mock: writes files on a timer) + + + +api->batch + + +file handoff + + + +remote + +external system +(remote: ExternalName, +reachable only from a VDI) + + + +api->remote + + +only when reachable + + + diff --git a/rig/docs/index.html b/rig/docs/index.html new file mode 100644 index 0000000..fe6bbc6 --- /dev/null +++ b/rig/docs/index.html @@ -0,0 +1,588 @@ + + + + + +rig — local environment installer + + + + +
+

RIG

+ local environment installer + +
+ +
+ + + +
+ +
+

Start here

+

A runnable local model of a large, regulated estate — legacy and new side by side.

+
+

rig builds a disposable Kubernetes environment on your machine so you can + explore how a system fits together without needing access to any of it. Its + job is onboarding and exploration, not a production replica.

+ +

Most services in it are deliberately not real. What has to be faithful + is the topology — the names, the ports, the dependency order, who can reach whom, + and how it fails. The workloads themselves are noise. This is what makes the + whole estate fit on a laptop: a real 20-service platform will not fit even once + on 14 GB, but mocks are about 30 MB each, so three faithful copies do.

+ +

The only prerequisite

+

Docker. No curl, no jq, no python, no apt repositories to configure.

+
# then, in the environment directory:
+make station     # is this workstation ready? reports, never fixes
+make deps        # install the pinned toolchain
+make cluster up  # build the cluster for the active profile
+
+

Read make station before make deps. It never changes + anything — it prints what it found and, at the end, the steps it cannot perform + for you.

+
+
+ +
+

The steps

+

Start to finish, in order, with what each one actually does.

+
+ +

1 · make station

+

Asks whether this workstation is ready. It changes nothing — it + reports what it found and, at the end, the things only a human can do + (anything needing sudo, or a Windows-side restart). Read it + before installing anything; it is faster than discovering the same problems + one failure at a time.

+
make station
+ +

2 · make setup

+

Does the preparation that can be automated: installs the pinned + toolchain if it is missing, checks PATH, Docker, and this environment's + ports. Every step is independently checked, so running it twice is safe and + running it half-configured finishes the job.

+

It does not stop at the first failure. A setup script that dies at + step two hides that steps four and five would also have failed, and on an + unfamiliar machine the complete list is the point. The tail of the output is + a to-do list of only what is outstanding.

+
make setup                  # host + toolchain
+make setup --share-docker   # ...and offer this machine's Docker to other distros
+
+ +

3 · make cluster up

+

Builds the cluster for the active profile. It prints what the profile + locks in before spending the time, because the CNI and the audit + policy are fixed at creation and cannot be changed afterwards.

+

Re-running is safe and, more importantly, convergent: if a first + attempt was interrupted before the CNI was installed, running it again + finishes the job rather than reporting "already exists" and leaving every + node permanently NotReady.

+
make cluster up                 # default profile
+make cluster up PROFILE=client  # three nodes, audit on, cached registry
+make cluster reset              # destroy and rebuild — the only way to change CNI or audit
+
+ +

4 · make docs

+

Serves this page from a throwaway container. Works with no cluster and + no toolchain, which is deliberate: these pages are the instructions for + building everything else, so they cannot depend on it.

+
make docs
+ +

Checking on things

+
+
make cluster list
Every cluster on the machine, its memory cost and its port block. The usual reason a new one will not start is an old one you forgot about; make cluster free frees them without deleting.
+
make ports
This environment's port block, and whether each is derived or overridden.
+
make registry
Which of the four registry modes is active, and where it points.
+
make dockerhost
Which WSL distro owns Docker and what this one is using.
+
+ +

Running more than one

+

Copy the directory, rename it, and repeat from step 2. Cluster name, + context, image tags and the port block all follow the directory name, so + the second environment collides with nothing and neither one's teardown can + reach the other.

+
cp -r rig ../platform-v2 && cd ../platform-v2
+make setup && make cluster up
+
+
+
+ +
+

Installation

+

A container installs onto the host and then gets out of the way.

+
+ Installation flow +
+
+

The installer is a container, not a shell script, for a specific reason: a + stock slim Debian has no curl, no wget, no + jq, no python3 and no CA bundle. A shell + installer could not make a verified HTTPS request, let alone check one. The + container carries its own toolchain, so the host needs nothing but Docker.

+ +

The cluster never runs inside that container. Everything it installs — + kind, kubectl, tilt, jq — runs natively afterwards, so nothing pays a + container tax during daily work.

+ +

Pinned and verified

+

Every tool is a single binary fetched at a pinned version and checked + against a published SHA256. Node images are pinned by digest, so + upgrading kind cannot silently move your Kubernetes version.

+ +

Not every machine should get cluster tooling

+

A managed or corporate-issued machine — the kind that holds the access + you cannot get anywhere else — is not somewhere to install development + tools by default. So the toolchain comes in two tiers:

+ + + + +
tierinstallsfor
corekubectl, jqtalk to a cluster someone else runs
dev+ kind, tiltbuild clusters and hot-reload into them
+
make deps core   # kubectl and jq only — nothing that creates a cluster
+make deps        # dev, the default
+make setup core  # same distinction, via setup
+
+

Testing in situ on a managed machine is still possible — install + the dev tier deliberately when you need it. The point is that + it should be a decision rather than a side effect of running setup.

+

The documentation itself needs neither tier: make docs + wants only Docker.

+ +

Air-gapped

+
make wizard full                       # bakes every binary into the image
+docker save …-wizard:full | gzip > rig.tgz
+# carry that one file in, then:
+docker load < rig.tgz && make cluster up PROFILE=offline
+
+
+
+ +
+

Environments

+

One directory is one environment. Copy it, rename it, run it.

+
+ Environment derivation +
+
+

Running several versions of a system at once means several clusters on one + machine, not several machines. Everything that could collide is derived from + the directory name:

+
+
cluster + context
acmebank/ builds acmebank on kind-acmebank.
+
port block
Ten ports from a hash of the name, in the 20000+ range — clear of 80, 443, 3000, 5432, 8000 and 8080.
+
registry + images
Named after the environment, so two copies never share one.
+
+

Two copies therefore never collide, and neither one's + make cluster down can touch the other. make ports + shows the block; make ports persist freezes it into + ctrl/.env if you want it fixed rather than derived.

+ +

Configuration layers

+

Weakest first, later wins: pinned versions → the profile → + ctrl/.env → the environment. So + make cluster up PROFILE=client always beats every file.

+
+
+ +
+

Profiles

+

Cluster shape is declared, not baked in.

+
+ + + + + +
profilenodesauditregistryfor
minimal1offnonefirst boot; assumes nothing
client3onmirrorthe regulated shape
offline1onlocalair-gapped
+ +

The audit policy cannot be changed later. It is an + apiserver flag, fixed when the cluster is created. cluster up + prints what a profile locks in before spending the time, and + make cluster reset is the way out.

+ +

LoadBalancer services

+

Real manifests use type: LoadBalancer, because a real + cluster has one. On a bare local cluster those Services sit at + EXTERNAL-IP <pending> forever, with no error anywhere — + the deployment looks healthy and simply is not reachable.

+

The metallb addon fixes that, so the same manifests work + here as upstream and nothing has to be rewritten to NodePort. Its address + pool is derived from the cluster's Docker network at install time rather + than hardcoded, because Docker picks that subnet and it differs between + machines.

+

Where those addresses are reachable from. The + pool lives on the Docker bridge, so LoadBalancer IPs work from the Linux + side — including from inside WSL. A browser on Windows has no route to + them. Use the ingress host ports for anything you need to open in a + browser.

+ +

Networking

+

The cluster uses kind's built-in networking, which does enforce + standard NetworkPolicy — verified against a no-policy control, not assumed. + The widely repeated claim that it accepts policies and silently ignores + them is out of date.

+

A pluggable CNI was tried and removed: it only added + GlobalNetworkPolicy, policy tiers and egress-CIDR rules, none of which are + needed yet, in exchange for a slower boot and one more thing that has to be + right at creation time. Worth revisiting only when a policy the built-in + cannot express actually comes up.

+ +

Memory

+

Every cluster is a running container tree whether you are using it or not. + make cluster list shows what exists and what it costs; + make cluster free stops the others without deleting them.

+
+
+ +
+

Registry

+

Local, cached, or straight to the corporate registry.

+
+ + + + + + +
modewhat it does
noneimages are built straight into the node
locala registry container wired into the cluster
mirrorthat container as a pull-through cache of the corporate registry
remoteno local container; pull direct with an imagePullSecret
+

mirror is what a locked-down network actually looks like: + images originate from the corporate registry, you do not hammer it, and you + keep working when the connection drops.

+ +

The corporate CA will bite you. A corporate + registry is usually behind an internal CA, and trust has to reach + three places: the host Docker daemon, every cluster node's containerd + (nodes do not inherit host trust), and any in-cluster client. Set + REGISTRY_CA_FILE and make station reports which is + still missing. The symptom otherwise is an opaque + x509: certificate signed by unknown authority.

+ +

Reachability also depends on where you are: if the registry is only + routable from a managed workspace, mirror and remote + will not resolve from a laptop at all. That is what local and + offline are for.

+
+
+ +
+

Architecture

+

The estate being modelled.

+

TODO — placeholder. The diagram below is + illustrative only: it shows how a mocked dependency, a service under active + work, and an unreachable remote system sit together. It is not the real + topology. Replace docs/graphs/03-architecture.dot with the + extracted platform diagrams, then run make docs graphs.

+
+ Estate topology (placeholder) +
+
+

Each component is one of three things, and switching between them should be + a one-line change rather than a rewrite:

+
+
real
Built from source and hot-reloaded. The thing you are actually working on — usually exactly one.
+
mock
A generic stub with canned responses. Everything you do not care about today.
+
remote
No pod at all: a Service of type ExternalName pointing at the real system. In-cluster DNS resolves identically, so callers never change.
+
+

The intended end state is that this diagram is generated from the + running cluster rather than drawn by hand — so it becomes a report of + what exists instead of a picture of what was once intended.

+
+
+ +
+

Troubleshooting

+

The failures that are hard to diagnose from their symptoms.

+
+

Tilt stops noticing file changes

+

Almost always inotify limits, and it fails silently — + nothing errors, changes just stop being picked up. Defaults on WSL are far too + low. make station reports it and prints the fix.

+ +

Cluster creation dies halfway with a port error

+

Docker reports failed to bind host port … address already in use + partway through creating the cluster. Run make station first — it + checks every port in this environment's block before anything is built.

+ +

Every node stays NotReady

+

Usually a cluster created with the default CNI disabled but the real CNI + never installed — typically an interrupted first run. Just run + make cluster up again: it converges rather than exiting early, and + will finish the missing steps.

+ +

x509: certificate signed by unknown authority

+

Corporate CA trust has not reached one of the three places it needs to be. + See Registry.

+ +

kubectl says the context does not exist

+

The cluster can exist while its context does not — a reset or a switched + KUBECONFIG loses it. make cluster up detects this and + re-exports the context.

+
+
+ +
+
+ + + + + diff --git a/rig/docs/viewer.html b/rig/docs/viewer.html new file mode 100644 index 0000000..214c382 --- /dev/null +++ b/rig/docs/viewer.html @@ -0,0 +1,101 @@ + + + + +Graph Viewer + + + +
+ +
+ + + diff --git a/rig/sample-rig/.gitignore b/rig/sample-rig/.gitignore new file mode 100644 index 0000000..e785dc1 --- /dev/null +++ b/rig/sample-rig/.gitignore @@ -0,0 +1,9 @@ +# NOTE: generated/ is deliberately NOT ignored. The artifact IS the deliverable — +# the whole point is a folder you copy, apply and boot without generating +# anything first. Regenerate it with `make manifest` after editing bundle.json +# or app/serve.py, and commit the result. +# +# (This differs from rig's ctrl/k8s/generated, which is a local build artifact.) + +__pycache__/ +*.pyc diff --git a/rig/sample-rig/Makefile b/rig/sample-rig/Makefile new file mode 100644 index 0000000..c4a3905 --- /dev/null +++ b/rig/sample-rig/Makefile @@ -0,0 +1,50 @@ +# Thin control Makefile — one target per ctrl/ script, subcommand as an +# argument. Same shape as rig's, for the same reason: the logic lives in the +# script, never here. +# +# make up -> ctrl/bundle.sh up +# make bundle down +# +# This directory is a BUNDLE, not an installer. It needs a cluster, which rig +# owns: +# +# cd .. && make cluster up # kind cluster for this environment +# make up # then deploy this bundle into it +# +# Start with: make up + +ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) +ifneq ($(ARGS),) +$(eval $(ARGS):;@:) +endif + +.DEFAULT_GOAL := help +.PHONY: help bundle manifest up down status url list dev + +help: ## list targets + @grep -hE '^[a-z]+:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16 + +bundle: ## the bundle [manifest|up|down|status|url|list|dev] + bash ctrl/bundle.sh $(or $(ARGS),status) + +# Shorthands for the ones used constantly. +manifest: ## regenerate generated/.yaml — no cluster needed + bash ctrl/bundle.sh manifest + +up: ## deploy this rig (installs MetalLB if absent) + bash ctrl/bundle.sh up + +down: ## remove this rig (leaves cluster, MetalLB, siblings) + bash ctrl/bundle.sh down + +status: ## what is deployed for this rig + bash ctrl/bundle.sh status + +url: ## the address MetalLB assigned + bash ctrl/bundle.sh url + +list: ## every rig in this cluster, with addresses + bash ctrl/bundle.sh list + +dev: ## run the UI locally with vite — no cluster needed + bash ctrl/bundle.sh dev diff --git a/rig/sample-rig/README.md b/rig/sample-rig/README.md new file mode 100644 index 0000000..198f762 --- /dev/null +++ b/rig/sample-rig/README.md @@ -0,0 +1,140 @@ +# sample-rig + +A minimal, non-sensitive bundle that proves an installation works and shows what +shipped. Copy it, rename it, and you have another rig. + +```bash +make manifest # generate the artifact — no cluster, no kubectl needed +make up # deploy it into the local cluster +make list # every rig in this cluster, with addresses +make dev # run the UI locally with vite, no cluster at all +``` + +`make up` prints an address. Open it and the page says **IT WORKS**, then lists +the tools and rigs in the bundle. + +## What it is for + +Three jobs, in the order you hit them: + +1. **Prove the install.** kind is there, a cluster exists, MetalLB hands out an + address, a `type: LoadBalancer` Service actually resolves, and a pod serves. + If all of that works, the environment is sound. +2. **Say what shipped.** The page renders [`bundle.json`](bundle.json) — + standalone tools and rigs, flat, with none of soleprint's internal hierarchy. + Editing that file is the only step needed to change the listing. +3. **Stand in for the real thing.** Nothing here is sensitive. The real + architecture connects separately, against a setup already known to work. + +## The UI is a complement, not the product + +`rig-ui/` is just a vite app. It complements a rig; a rig is complete and useful +without it, and nothing depends on it being there. It is deliberately **not** +generated by kind or tilt — you copy the folder into a rig after that rig is +pulled, and apply one manifest: + +```bash +kubectl apply -n -f rig-ui/k8s.yaml +``` + +That file is the whole integration: one Pod running `npm run dev` on +`node:22-alpine`, one Service. A bare Pod rather than a Deployment because this +is a dev-loop convenience, not a workload to keep alive. + +The app and `bundle.json` arrive as a ConfigMap, so nothing is baked into an +image and editing the bundle is the entire update cycle. The container runs +`npm install` at start, which needs egress to a registry — on a locked-down +cluster point npm at the internal one, or bake an image instead. Nothing else +changes if you do. + +## One artifact, two destinations + +`ctrl/manifest.py` emits `generated/.yaml` — namespace, the app and +bundle embedded in a ConfigMap, Pod, Service. It is self-contained and applies +unmodified anywhere: + +```bash +kubectl apply -f generated/sample-rig.yaml # local kind, or an external cluster +``` + +`make up` applies **that same file**. There is no separate local path, so what +works here cannot quietly differ from what is applied elsewhere. + +This is what `type: LoadBalancer` buys. MetalLB answers it on kind; the AWS load +balancer controller answers it on EKS. NodePort would not survive the trip — it +is a single cluster-wide port range, so two rigs would have to negotiate numbers. + +**VPC-agnostic on purpose.** The target is EKS, but the Service carries no +annotations — no `aws-load-balancer-subnets`, no security groups, no `-scheme`, +no `-type: nlb`. Each of those encodes a specific network layout, and one of them +appearing here would pin the artifact to the account and VPC it was written +against, which is precisely what stops it also working on kind. Subnet discovery +is the cluster's business: EKS resolves it from the tags its own subnets carry. + +That leaves one thing genuinely environment-specific — internal versus +internet-facing. A bare `LoadBalancer` provisions internet-facing, which a +regulated account will usually refuse, and should. That belongs in a +per-environment overlay applied on top, never inlined into this artifact. + +**MetalLB only — no ingress-nginx.** Its controller supports a narrow window of +Kubernetes versions, so depending on it constrains which k8s a rig can be built +with. That undercuts running trailing-edge control planes to model a legacy +estate, which is the reason `versions.env` pins v1_33..v1_36. MetalLB carries no +such constraint, so reachability costs nothing in version coverage. + +## Several rigs, one cluster + +Identity follows the **folder name**, the same rule rig uses for cluster +identity. The namespace is the folder; resource names are generic, and names only +have to be unique within a namespace. + +```bash +cp -r sample-rig corporate-rig +cd corporate-rig && make up # its own namespace, its own address +``` + +No edits, no collisions, both in the same local cluster. `make list` shows them +together. `make down` removes only this one — siblings, MetalLB and the cluster +are left alone. + +Client rigs are gitignored (`*-rig/`, with `sample-rig/` the deliberate +exception): a rig's k8s files spell out a real architecture, and that is exactly +what must not land in this repo. + +## Staging workstations + +`ctrl/manifest.py` is stdlib-only on purpose: it runs on a bare machine before +anything is installed. The toolchain itself is rig's job — `make deps` installs +the pinned kind and tilt binaries, which is what makes a staging AWS workspace +reachable from the same commands as a laptop. + +## Layout + +``` +sample-rig/ +├── Makefile # thin — one target per ctrl/ script +├── bundle.json # what shipped; the UI renders THIS +├── rig-ui/ # the vite app — optional, copied into a rig to enable it +│ ├── k8s.yaml # how to plug it in: one Pod, one Service +│ ├── index.html +│ ├── package.json +│ ├── vite.config.js +│ └── src/{main.js,style.css} +├── ctrl/ +│ ├── manifest.py # emits the artifact +│ └── bundle.sh # generate / deploy / inspect +└── generated/ # the artifact — committed, this is the deliverable +``` + +Editing `bundle.json` or anything in `rig-ui/` means re-running `make manifest`. +The ConfigMap carries a checksum of everything embedded, so a stale deployment is +visible rather than silent. + +## Not built, but not foreclosed + +Everything derives from `bundle.json` plus a target namespace. A Pulumi or +Terraform emitter would sit beside `ctrl/manifest.py` consuming the same inputs; +nothing above it assumes the artifact is YAML. + +Licence terms for the compiled UI component belong in the soleprint-generated +bundle, not here — this sample carries no proprietary component. diff --git a/rig/sample-rig/bundle.json b/rig/sample-rig/bundle.json new file mode 100644 index 0000000..1fa77db --- /dev/null +++ b/rig/sample-rig/bundle.json @@ -0,0 +1,51 @@ +{ + "_comment": "What this bundle contains. Single source of truth — the landing page renders THIS file, so adding an entry here is the only edit needed. Deliberately FLAT: standalone tools and rigs, with none of soleprint's internal hierarchy (no artery/atlas/station layering). Nothing here is sensitive; the real architecture connects separately.", + "bundle": { + "name": "sample-rig", + "description": "Non-sensitive sample bundle. Proves the kind install works and shows what ships.", + "sensitive": false + }, + "tools": [ + { + "name": "modelgen", + "summary": "Generate models from config", + "standalone": true + }, + { + "name": "datagen", + "summary": "Generate test data from rig-owned generators", + "standalone": true + }, + { + "name": "graphgen", + "summary": "Generate navigable model graphs", + "standalone": true + }, + { + "name": "tester", + "summary": "HTTP contract test runner — one suite, any environment", + "standalone": true + }, + { + "name": "databrowse", + "summary": "SQL data browser", + "standalone": true + }, + { + "name": "sbwrapper", + "summary": "Sandbox wrapper", + "standalone": true + } + ], + "rigs": [ + { + "name": "sample-rig", + "summary": "This bundle — a minimal, copyable environment", + "active": true + } + ], + "next": [ + "Point MANIFESTS_DIR at the real manifests to connect the actual architecture.", + "Real k8s files are versioned separately and are not part of this bundle." + ] +} diff --git a/rig/sample-rig/cluster.mock.json b/rig/sample-rig/cluster.mock.json new file mode 100644 index 0000000..2a80708 --- /dev/null +++ b/rig/sample-rig/cluster.mock.json @@ -0,0 +1,40 @@ +{ + "_comment": "MOCKED cluster state. Nothing here is read from a live cluster — it exists so the UI can be shown when there is no cluster at all (a locked-down machine, a laptop with no memory to spare, a demo where kind will not start). The page labels it as mocked; a demo that looks live but is not is worse than one that says so. When a real cluster is present the same shapes come from kubectl.", + + "mocked": true, + + "cluster": { + "name": "sample-rig", + "context": "kind-sample-rig", + "provider": "kind", + "profile": "minimal", + "k8s": "v1.36.1", + "nodes": 1 + }, + + "workloads": [ + { + "name": "rig-ui", + "summary": "Pod · node:22-alpine · vite on :5173", + "state": "Running" + }, + { + "name": "metallb-system/controller", + "summary": "Deployment · assigns LoadBalancer addresses", + "state": "Running" + }, + { + "name": "metallb-system/speaker", + "summary": "DaemonSet · answers ARP in layer 2 mode", + "state": "Running" + } + ], + + "services": [ + { + "name": "rig-ui", + "summary": "LoadBalancer · 80 -> 5173 · no annotations, so it resolves on kind and on EKS alike", + "state": "172.18.255.200" + } + ] +} diff --git a/rig/sample-rig/ctrl/bundle.sh b/rig/sample-rig/ctrl/bundle.sh new file mode 100755 index 0000000..f6e0e06 --- /dev/null +++ b/rig/sample-rig/ctrl/bundle.sh @@ -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/.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 diff --git a/rig/sample-rig/ctrl/manifest.py b/rig/sample-rig/ctrl/manifest.py new file mode 100644 index 0000000..cd26199 --- /dev/null +++ b/rig/sample-rig/ctrl/manifest.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Emit the complete, self-contained deployment for this rig. + + python3 ctrl/manifest.py [namespace] > generated/.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)) diff --git a/rig/sample-rig/generated/sample-rig.yaml b/rig/sample-rig/generated/sample-rig.yaml new file mode 100644 index 0000000..73fe7e3 --- /dev/null +++ b/rig/sample-rig/generated/sample-rig.yaml @@ -0,0 +1,406 @@ +# 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: sample-rig + labels: + rig.bundle/name: sample-rig +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: rig-ui + namespace: sample-rig + labels: + rig.bundle/checksum: "2074194964" +data: + bundle.json: | + { + "_comment": "What this bundle contains. Single source of truth — the landing page renders THIS file, so adding an entry here is the only edit needed. Deliberately FLAT: standalone tools and rigs, with none of soleprint's internal hierarchy (no artery/atlas/station layering). Nothing here is sensitive; the real architecture connects separately.", + "bundle": { + "name": "sample-rig", + "description": "Non-sensitive sample bundle. Proves the kind install works and shows what ships.", + "sensitive": false + }, + "tools": [ + { + "name": "modelgen", + "summary": "Generate models from config", + "standalone": true + }, + { + "name": "datagen", + "summary": "Generate test data from rig-owned generators", + "standalone": true + }, + { + "name": "graphgen", + "summary": "Generate navigable model graphs", + "standalone": true + }, + { + "name": "tester", + "summary": "HTTP contract test runner — one suite, any environment", + "standalone": true + }, + { + "name": "databrowse", + "summary": "SQL data browser", + "standalone": true + }, + { + "name": "sbwrapper", + "summary": "Sandbox wrapper", + "standalone": true + } + ], + "rigs": [ + { + "name": "sample-rig", + "summary": "This bundle — a minimal, copyable environment", + "active": true + } + ], + "next": [ + "Point MANIFESTS_DIR at the real manifests to connect the actual architecture.", + "Real k8s files are versioned separately and are not part of this bundle." + ] + } + index.html: | + + + + + + IT WORKS + + +
+ + + + main.js: | + import "./style.css"; + + /* The IT WORKS page: renders bundle.json as the list of what shipped. + * + * Plain vite, no framework — this is a complement to the rig, not part of it, + * and it should stay small enough that nobody has to adopt a stack to read it. + * + * bundle.json is fetched at runtime rather than imported, so the same built app + * serves whatever rig it was copied into. Editing the ConfigMap changes the page + * without rebuilding. + * + * Styling is a handful of rules on purpose. The real visual identity lives in + * the soleprint UI package; nothing here should grow into a theme. + */ + + const esc = (s) => + String(s).replace(/&/g, "&").replace(//g, ">"); + + const tag = (text, on = false) => + `${esc(text)}`; + + function items(list, activeKey) { + if (!list?.length) return `
  • nothing listed
  • `; + return list + .map((it) => { + const tags = [ + it.standalone ? tag("standalone") : "", + it.state ? tag(it.state) : "", + activeKey && it[activeKey] ? tag("active", true) : "", + ].join(""); + return `
  • ${esc(it.name ?? "?")} + ${esc(it.summary ?? "")}${tags}
  • `; + }) + .join(""); + } + + /* Cluster state, when there is any to show. + * + * Fetched separately and allowed to fail: the bundle listing is the point, and a + * rig with no cluster reachable is a normal state, not an error. Renders nothing + * at all when absent. + * + * When the payload says `mocked`, say so loudly. This exists to demo the UI on a + * machine where kind will not run — and a demo that looks live but is not is + * worse than one that admits it. */ + function clusterSection(c) { + if (!c) return ""; + const m = c.cluster ?? {}; + const banner = c.mocked + ? `

    mocked — no cluster was queried; these are canned values

    ` + : ""; + const meta = [m.context, m.k8s, m.profile ? `profile ${m.profile}` : "", + m.nodes ? `${m.nodes} node${m.nodes > 1 ? "s" : ""}` : ""] + .filter(Boolean).join(" · "); + + return ` +

    Cluster${c.mocked ? " (mocked)" : ""}

    + ${banner} + ${meta ? `

    ${esc(meta)}

    ` : ""} +
      ${items(c.workloads)}
    +

    Services (${c.services?.length ?? 0})

    +
      ${items(c.services)}
    `; + } + + function render(b, name, cluster) { + const meta = b.bundle ?? {}; + const next = (b.next ?? []).map((n) => `
  • ${esc(n)}
  • `).join(""); + return ` +

    IT WORKS — ${esc(name || meta.name || "rig")}

    +

    ${esc(meta.description ?? "")}

    + +

    Tools (${b.tools?.length ?? 0})

    +
      ${items(b.tools)}
    + +

    Rigs (${b.rigs?.length ?? 0})

    +
      ${items(b.rigs, "active")}
    + + ${clusterSection(cluster)} + + ${next ? `` : ""}`; + } + + const app = document.getElementById("app"); + + const json = (path, required) => + fetch(path).then((r) => { + if (r.ok) return r.json(); + if (required) throw new Error(`${path} -> HTTP ${r.status}`); + return null; // optional: absent is a normal state, not an error + }).catch((err) => { + if (required) throw err; + return null; + }); + + Promise.all([json("/bundle.json", true), json("/cluster.mock.json", false)]) + // RIG_NAME is injected by vite from the pod env, so two rigs sharing a + // cluster are distinguishable even if a copied bundle.json kept its old name. + .then(([b, cluster]) => { + app.innerHTML = render(b, import.meta.env.VITE_RIG_NAME, cluster); + }) + .catch((err) => { + app.innerHTML = `

    bundle unavailable

    +

    ${esc(err.message)}

    `; + }); + package.json: | + { + "name": "rig-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0 --port 5173", + "build": "vite build", + "preview": "vite preview --host 0.0.0.0 --port 5173" + }, + "devDependencies": { + "vite": "^6" + } + } + style.css: | + /* Minimal, self-contained. The real visual identity ships with the soleprint UI + package, which is a separate artifact — nothing here should grow into a theme. */ + + body { + margin: 0; + padding: 2.5rem 1.5rem; + background: #0d0d0f; + color: #e8e8f0; + font: 14px/1.6 ui-monospace, "JetBrains Mono", Menlo, monospace; + } + main { max-width: 52rem; margin: 0 auto; } + + h1 { margin: 0; font-size: 1.6rem; letter-spacing: 0.02em; } + h1 .ok { color: #3ecf8e; } + h1.err { color: #f06565; } + .sub { color: #8888a0; margin: 0.35rem 0 2.25rem; } + + h2 { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: #8888a0; + margin: 2rem 0 0.75rem; + font-weight: 600; + } + + ul { list-style: none; margin: 0; padding: 0; } + li { + display: flex; + gap: 0.75rem; + align-items: baseline; + padding: 0.5rem 0.75rem; + border: 1px solid #2e2e38; + border-radius: 6px; + margin-bottom: 0.4rem; + background: #16161a; + } + .name { font-weight: 600; min-width: 9rem; } + .summary { color: #8888a0; flex: 1; } + + .tag { + font-size: 0.7rem; + padding: 0.1rem 0.45rem; + border-radius: 3px; + background: #26262f; + color: #8888a0; + white-space: nowrap; + } + .tag.on { background: #3ecf8e; color: #0d0d0f; } + + .next { + color: #555568; + font-size: 0.8rem; + margin-top: 2.5rem; + border-top: 1px solid #2e2e38; + padding-top: 1rem; + } + .next li { + display: list-item; + border: 0; + background: none; + padding: 0.15rem 0; + margin: 0 0 0 1.1rem; + list-style: disc; + } + + /* Mocked-data banner. Deliberately loud: this only appears when the cluster + payload is canned, and a demo that looks live but is not is worse than one + that says so. */ + .mock { + margin: 0 0 0.75rem; + padding: 0.4rem 0.75rem; + border: 1px dashed #f5a623; + border-radius: 6px; + color: #f5a623; + font-size: 0.8rem; + } + vite.config.js: | + import { defineConfig } from "vite"; + + /* Serves on 0.0.0.0 so the pod is reachable through the Service, and allows any + * Host header because the address is assigned at runtime (MetalLB locally, a + * cloud load balancer on EKS) and is never known at build time. */ + export default defineConfig({ + server: { host: "0.0.0.0", port: 5173, strictPort: true, allowedHosts: true }, + preview: { host: "0.0.0.0", port: 5173, strictPort: true, allowedHosts: true }, + }); +--- +# How to plug the UI into whatever k8s you generated. THIS IS THE WHOLE THING: +# one Pod running the vite app, one Service to reach it. +# +# Optional by design. The UI complements a rig; it is not part of the end +# product, and a rig is complete and useful without it. Apply this only when you +# want the listing: +# +# kubectl apply -n -f rig-ui/k8s.yaml +# +# A bare Pod, not a Deployment — this is a dev-loop convenience, not a workload +# to keep alive. If it dies you re-apply it; nothing depends on it staying up. +# +# The app and bundle.json arrive as a ConfigMap named `rig-ui`, which +# ctrl/manifest.py generates from the folder. Nothing is baked into an image, so +# editing bundle.json and re-applying is the whole update cycle. +apiVersion: v1 +kind: Pod +metadata: + name: rig-ui + namespace: sample-rig + labels: + app: rig-ui +spec: + containers: + - name: vite + image: node:22-alpine + workingDir: /app + # npm install at start: no image to build and no registry to publish to, + # which is the point of a minimal plug-in. It needs egress to a registry — + # on a locked-down cluster point npm at the internal one, or bake an image + # instead. Nothing else here changes if you do. + command: ["sh", "-c"] + # A ConfigMap mounts flat (keys cannot contain '/'), so the files are + # placed into vite's expected layout here. bundle.json goes to public/ + # because that is what vite serves at /bundle.json, which is where the + # app fetches it. + args: + - | + mkdir -p /app/src /app/public && + cp /src/package.json /src/vite.config.js /src/index.html /app/ && + cp /src/main.js /src/style.css /app/src/ && + cp /src/bundle.json /app/public/ && + npm install --no-audit --no-fund && + npm run dev + env: + # Rendered in the heading so two rigs sharing a cluster stay + # distinguishable. Set from the namespace by ctrl/manifest.py. + - name: VITE_RIG_NAME + value: sample-rig + ports: + - name: http + containerPort: 5173 + volumeMounts: + # /src is read-only from the ConfigMap; the app is copied to a writable + # /app because npm install has to create node_modules. + - name: rig-ui + mountPath: /src + - name: app + mountPath: /app + readinessProbe: + httpGet: { path: /, port: 5173 } + # npm install decides how long this takes, and it is the slow part. + initialDelaySeconds: 15 + periodSeconds: 5 + failureThreshold: 30 + resources: + requests: { memory: 128Mi, cpu: 50m } + limits: { memory: 512Mi } + volumes: + - name: rig-ui + configMap: + name: rig-ui + - name: app + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: rig-ui + namespace: sample-rig + labels: + app: rig-ui + # No annotations, deliberately — see k8s/app.yaml. The target is EKS but this + # stays VPC-agnostic: no subnets, no security groups, no -scheme, no -type. + # A bare LoadBalancer is what lets one manifest work on kind and on EKS. +spec: + type: LoadBalancer + selector: + app: rig-ui + ports: + - name: http + port: 80 + targetPort: 5173 + protocol: TCP + # Pinned, because a LoadBalancer Service also allocates a NodePort and + # this is the only address that works everywhere. + # + # On WSL the MetalLB address is on a docker bridge INSIDE the Linux VM, + # and Windows has no route to it — the page looks broken while the + # cluster is perfectly healthy. 30080 is what rig's `hostport` ingress + # mode publishes to the host, so this is reachable at + # localhost:$HTTP_PORT from a Windows browser with nothing configured. + # + # Costs nothing elsewhere: MetalLB still assigns an external IP on Linux, + # and on EKS the load balancer targets this NodePort anyway. One Service, + # no per-environment branch. + # + # A pinned NodePort is cluster-unique, so two rigs must live in separate + # clusters — which is how they are run anyway. + nodePort: 30080 diff --git a/rig/sample-rig/rig-ui/.gitignore b/rig/sample-rig/rig-ui/.gitignore new file mode 100644 index 0000000..bea2f92 --- /dev/null +++ b/rig/sample-rig/rig-ui/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +public/ +dist/ diff --git a/rig/sample-rig/rig-ui/index.html b/rig/sample-rig/rig-ui/index.html new file mode 100644 index 0000000..3230fbf --- /dev/null +++ b/rig/sample-rig/rig-ui/index.html @@ -0,0 +1,12 @@ + + + + + + IT WORKS + + +
    + + + diff --git a/rig/sample-rig/rig-ui/k8s.yaml b/rig/sample-rig/rig-ui/k8s.yaml new file mode 100644 index 0000000..721765c --- /dev/null +++ b/rig/sample-rig/rig-ui/k8s.yaml @@ -0,0 +1,108 @@ +# How to plug the UI into whatever k8s you generated. THIS IS THE WHOLE THING: +# one Pod running the vite app, one Service to reach it. +# +# Optional by design. The UI complements a rig; it is not part of the end +# product, and a rig is complete and useful without it. Apply this only when you +# want the listing: +# +# kubectl apply -n -f rig-ui/k8s.yaml +# +# A bare Pod, not a Deployment — this is a dev-loop convenience, not a workload +# to keep alive. If it dies you re-apply it; nothing depends on it staying up. +# +# The app and bundle.json arrive as a ConfigMap named `rig-ui`, which +# ctrl/manifest.py generates from the folder. Nothing is baked into an image, so +# editing bundle.json and re-applying is the whole update cycle. +apiVersion: v1 +kind: Pod +metadata: + name: rig-ui + labels: + app: rig-ui +spec: + containers: + - name: vite + image: node:22-alpine + workingDir: /app + # npm install at start: no image to build and no registry to publish to, + # which is the point of a minimal plug-in. It needs egress to a registry — + # on a locked-down cluster point npm at the internal one, or bake an image + # instead. Nothing else here changes if you do. + command: ["sh", "-c"] + # A ConfigMap mounts flat (keys cannot contain '/'), so the files are + # placed into vite's expected layout here. bundle.json goes to public/ + # because that is what vite serves at /bundle.json, which is where the + # app fetches it. + args: + - | + mkdir -p /app/src /app/public && + cp /src/package.json /src/vite.config.js /src/index.html /app/ && + cp /src/main.js /src/style.css /app/src/ && + cp /src/bundle.json /app/public/ && + npm install --no-audit --no-fund && + npm run dev + env: + # Rendered in the heading so two rigs sharing a cluster stay + # distinguishable. Set from the namespace by ctrl/manifest.py. + - name: VITE_RIG_NAME + value: __RIG_NAME__ + ports: + - name: http + containerPort: 5173 + volumeMounts: + # /src is read-only from the ConfigMap; the app is copied to a writable + # /app because npm install has to create node_modules. + - name: rig-ui + mountPath: /src + - name: app + mountPath: /app + readinessProbe: + httpGet: { path: /, port: 5173 } + # npm install decides how long this takes, and it is the slow part. + initialDelaySeconds: 15 + periodSeconds: 5 + failureThreshold: 30 + resources: + requests: { memory: 128Mi, cpu: 50m } + limits: { memory: 512Mi } + volumes: + - name: rig-ui + configMap: + name: rig-ui + - name: app + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: rig-ui + labels: + app: rig-ui + # No annotations, deliberately — see k8s/app.yaml. The target is EKS but this + # stays VPC-agnostic: no subnets, no security groups, no -scheme, no -type. + # A bare LoadBalancer is what lets one manifest work on kind and on EKS. +spec: + type: LoadBalancer + selector: + app: rig-ui + ports: + - name: http + port: 80 + targetPort: 5173 + protocol: TCP + # Pinned, because a LoadBalancer Service also allocates a NodePort and + # this is the only address that works everywhere. + # + # On WSL the MetalLB address is on a docker bridge INSIDE the Linux VM, + # and Windows has no route to it — the page looks broken while the + # cluster is perfectly healthy. 30080 is what rig's `hostport` ingress + # mode publishes to the host, so this is reachable at + # localhost:$HTTP_PORT from a Windows browser with nothing configured. + # + # Costs nothing elsewhere: MetalLB still assigns an external IP on Linux, + # and on EKS the load balancer targets this NodePort anyway. One Service, + # no per-environment branch. + # + # A pinned NodePort is cluster-unique, so two rigs must live in separate + # clusters — which is how they are run anyway. + nodePort: 30080 diff --git a/rig/sample-rig/rig-ui/package-lock.json b/rig/sample-rig/rig-ui/package-lock.json new file mode 100644 index 0000000..ae894f1 --- /dev/null +++ b/rig/sample-rig/rig-ui/package-lock.json @@ -0,0 +1,1164 @@ +{ + "name": "rig-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rig-ui", + "version": "0.1.0", + "devDependencies": { + "vite": "^6" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/rig/sample-rig/rig-ui/package.json b/rig/sample-rig/rig-ui/package.json new file mode 100644 index 0000000..1c54fe6 --- /dev/null +++ b/rig/sample-rig/rig-ui/package.json @@ -0,0 +1,14 @@ +{ + "name": "rig-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0 --port 5173", + "build": "vite build", + "preview": "vite preview --host 0.0.0.0 --port 5173" + }, + "devDependencies": { + "vite": "^6" + } +} diff --git a/rig/sample-rig/rig-ui/src/main.js b/rig/sample-rig/rig-ui/src/main.js new file mode 100644 index 0000000..bf23b29 --- /dev/null +++ b/rig/sample-rig/rig-ui/src/main.js @@ -0,0 +1,136 @@ +import "./style.css"; + +/* The IT WORKS page: renders bundle.json as the list of what shipped. + * + * Plain vite, no framework — this is a complement to the rig, not part of it, + * and it should stay small enough that nobody has to adopt a stack to read it. + * + * Laid out like soleprint's templated vein pages, because it does the same job: + * name each component, list what it exposes, show what comes back. Tool chrome + * and output are styled apart on purpose (see style.css) — that separation is + * what tells you whether you are reading the tool or its result. + * + * bundle.json is fetched at runtime rather than imported, so the same built app + * serves whatever rig it was copied into. Editing the ConfigMap changes the page + * without rebuilding. + */ + +const esc = (s) => + String(s).replace(/&/g, "&").replace(//g, ">"); + +const tag = (text, on = false) => + `${esc(text)}`; + +/* Tool chrome: one bordered card per component. */ +function components(list, activeKey) { + if (!list?.length) + return `

    nothing listed

    `; + return list + .map((it) => { + const tags = [ + it.standalone ? tag("standalone") : "", + it.state ? tag(it.state) : "", + activeKey && it[activeKey] ? tag("active", true) : "", + ].join(""); + return `
    +

    ${esc(it.name ?? "?")} ${tags}

    +

    ${esc(it.summary ?? "")}

    +
    `; + }) + .join(""); +} + +/* Endpoint rows: path on the left, what it returns on the right. */ +function endpoints(list) { + return list + .map( + (e) => `
  • ${esc(e.path)} + ${esc(e.desc)}
  • ` + ) + .join(""); +} + +/* Output: what the endpoint above actually returns, so the page demonstrates + itself rather than describing what a demonstration would look like. */ +function example(bundle) { + const sample = { + bundle: bundle.bundle?.name, + tools: (bundle.tools ?? []).map((t) => t.name), + rigs: (bundle.rigs ?? []).map((r) => r.name), + }; + return `
    ${esc(JSON.stringify(sample, null, 2))}
    `; +} + +/* Cluster state, when there is any to show. + * + * Fetched separately and allowed to fail: the bundle listing is the point, and a + * rig with no cluster reachable is a normal state, not an error. Renders nothing + * at all when absent. When the payload says `mocked`, say so loudly. */ +function clusterSection(c) { + if (!c) return ""; + const m = c.cluster ?? {}; + const meta = [m.context, m.k8s, m.profile && `profile ${m.profile}`, + m.nodes && `${m.nodes} node${m.nodes > 1 ? "s" : ""}`] + .filter(Boolean).join(" · "); + + return ` +

    Cluster${c.mocked ? " (mocked)" : ""}

    + ${c.mocked ? `

    mocked — no cluster was queried; these are canned values

    ` : ""} + ${meta ? `

    ${esc(meta)}

    ` : ""} +
    ${components(c.workloads)}
    + +

    Services

    +
    ${components(c.services)}
    `; +} + +function render(b, name, cluster) { + const meta = b.bundle ?? {}; + const next = (b.next ?? []).map((n) => `
  • ${esc(n)}
  • `).join(""); + return ` +

    IT WORKS — ${esc(name || meta.name || "rig")}

    +

    ${esc(meta.description ?? "")}

    + +

    Tools (${b.tools?.length ?? 0})

    +
    ${components(b.tools)}
    + +

    Rigs (${b.rigs?.length ?? 0})

    +
    ${components(b.rigs, "active")}
    + +

    Endpoints

    +
      ${endpoints([ + { path: "/", desc: "this page" }, + { path: "/bundle.json", desc: "the manifest it renders" }, + ])}
    + +

    Example — GET /bundle.json

    + ${example(b)} + + ${clusterSection(cluster)} + + ${next ? `` : ""}`; +} + +const app = document.getElementById("app"); + +const json = (path, required) => + fetch(path) + .then((r) => { + if (r.ok) return r.json(); + if (required) throw new Error(`${path} -> HTTP ${r.status}`); + return null; // optional: absent is a normal state, not an error + }) + .catch((err) => { + if (required) throw err; + return null; + }); + +Promise.all([json("/bundle.json", true), json("/cluster.mock.json", false)]) + // RIG_NAME is injected by vite from the pod env, so two rigs sharing a + // cluster are distinguishable even if a copied bundle.json kept its old name. + .then(([b, cluster]) => { + app.innerHTML = render(b, import.meta.env.VITE_RIG_NAME, cluster); + }) + .catch((err) => { + app.innerHTML = `

    bundle unavailable

    +

    ${esc(err.message)}

    `; + }); diff --git a/rig/sample-rig/rig-ui/src/style.css b/rig/sample-rig/rig-ui/src/style.css new file mode 100644 index 0000000..5591a90 --- /dev/null +++ b/rig/sample-rig/rig-ui/src/style.css @@ -0,0 +1,139 @@ +/* Minimal and self-contained — no framework dependency. + * + * The visual language follows soleprint's templated vein pages, because this + * page does the same job: say what a component is, list what it exposes, and + * show what comes back. Two treatments, deliberately distinct: + * + * TOOL CHROME bordered cards on the darker background, accent-coloured + * titles, endpoint rows separated by rules. + * OUTPUT a lighter raised block, monospace, pre-wrap and selectable — + * it is data, not furniture, and should read as a payload. + * + * Keeping them apart matters more than either looks: it is what tells you at a + * glance whether you are reading the tool or the thing it produced. */ + +:root { + --bg: #0d0d0f; + --surface: #16161a; + --surface-raised: #1e1e24; + --border: #2e2e38; + --border-strong: #3d3d4a; + --text: #e8e8f0; + --muted: #8888a0; + --accent: #3ecf8e; + --accent-dim: #f5a623; + --mono: "JetBrains Mono", "Cascadia Mono", Consolas, ui-monospace, monospace; +} + +body { + margin: 0; + padding: 2.5rem 1.5rem; + background: var(--bg); + color: var(--text); + font: 14px/1.6 var(--mono); +} +main { max-width: 56rem; margin: 0 auto; } + +h1 { margin: 0; font-size: 1.6rem; letter-spacing: 0.02em; } +h1 .ok { color: var(--accent); } +h1.err { color: #f06565; } +.tagline { color: var(--muted); margin: 0.35rem 0 2.25rem; } + +h2 { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--muted); + margin: 2.25rem 0 0.75rem; + font-weight: 600; +} + +/* ── tool chrome ────────────────────────────────────────────────────────── */ + +.components { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 0.75rem; +} + +.component { + background: var(--bg); + border: 1px solid var(--border-strong); + border-radius: 8px; + padding: 0.75rem; +} +.component h4 { + margin: 0 0 0.25rem; + font-size: 0.95rem; + color: var(--accent); +} +.component p { + margin: 0; + font-size: 0.85rem; + color: var(--muted); +} + +.endpoints { list-style: none; margin: 0; padding: 0; } +.endpoints li { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.6rem 0; + border-bottom: 1px solid var(--border-strong); +} +.endpoints li:last-child { border-bottom: none; } +.endpoints code { + background: var(--surface); + color: var(--accent); + padding: 0.25rem 0.5rem; + border-radius: 4px; +} +.endpoints .desc { color: var(--muted); font-size: 0.9rem; } + +.tag { + font-size: 0.7rem; + padding: 0.1rem 0.45rem; + border-radius: 3px; + background: var(--border); + color: var(--muted); + white-space: nowrap; +} +.tag.on { background: var(--accent); color: var(--bg); } + +/* ── output ─────────────────────────────────────────────────────────────── */ + +.output { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem; + font-size: 0.85rem; + white-space: pre-wrap; + word-break: break-word; + user-select: text; + color: var(--text); + margin: 0; +} +.output .k { color: var(--accent); } + +/* Mocked-data banner. Loud on purpose: it only appears when the payload is + canned, and a demo that looks live but is not is worse than one that says so. */ +.mock { + margin: 0 0 0.75rem; + padding: 0.4rem 0.75rem; + border: 1px dashed var(--accent-dim); + border-radius: 6px; + color: var(--accent-dim); + font-size: 0.8rem; +} + +.next { + color: #555568; + font-size: 0.8rem; + margin-top: 2.5rem; + border-top: 1px solid var(--border); + padding-top: 1rem; +} +.next ul { margin: 0; padding: 0 0 0 1.1rem; } +.next li { list-style: disc; padding: 0.15rem 0; } diff --git a/rig/sample-rig/rig-ui/vite.config.js b/rig/sample-rig/rig-ui/vite.config.js new file mode 100644 index 0000000..1b1c6e4 --- /dev/null +++ b/rig/sample-rig/rig-ui/vite.config.js @@ -0,0 +1,9 @@ +import { defineConfig } from "vite"; + +/* Serves on 0.0.0.0 so the pod is reachable through the Service, and allows any + * Host header because the address is assigned at runtime (MetalLB locally, a + * cloud load balancer on EKS) and is never known at build time. */ +export default defineConfig({ + server: { host: "0.0.0.0", port: 5173, strictPort: true, allowedHosts: true }, + preview: { host: "0.0.0.0", port: 5173, strictPort: true, allowedHosts: true }, +});