Compare commits

..

60 Commits

Author SHA1 Message Date
2ef6139957 bring secrets per repo 2026-09-22 14:00:43 -03:00
55c0d73ffe update dataconvert and source 2026-09-22 11:57:31 -03:00
0b227cbc0e Merge branch 'rig-work' 2026-09-22 08:18:31 -03:00
60527645f2 distilled tools 2026-09-22 08:18:25 -03:00
dee899d86f installer 2026-09-22 08:18:02 -03:00
8f7e409ec4 Merge branch 'rig-work' 2026-09-22 06:47:55 -03:00
f4b2c15a9f update distill 2026-09-22 06:22:04 -03:00
2a0a793f19 rig major updates 2026-09-22 05:15:49 -03:00
Mariano Gabriel
aeeb26f4e9 update distill example 2026-09-22 04:40:43 -03:00
9c963514f1 Merge branch 'rig-work' 2026-09-17 15:01:55 -03:00
565cecfb50 simpler check and deps messages 2026-09-17 15:01:48 -03:00
1752a95408 Merge branch 'rig-work' 2026-09-17 01:18:05 -03:00
1dc9d38c80 clean rig 2026-09-17 01:12:15 -03:00
6a005dae3b Merge branch 'rig-work' 2026-09-17 00:39:06 -03:00
de5b1b7ea8 rig updates 2026-09-17 00:39:00 -03:00
6dbc83a449 Merge branch 'rig-work' 2026-09-17 00:14:37 -03:00
730ebaff2f remove profile dependency 2026-09-17 00:14:27 -03:00
dd17021402 Merge branch 'rig-work' 2026-09-17 00:07:37 -03:00
19feac6d57 remove profile dependency 2026-09-17 00:07:24 -03:00
809a13eebe distill updates 2026-09-16 23:14:27 -03:00
004b397b94 Merge branch 'rig-work' 2026-09-16 19:14:54 -03:00
29e30693ea rig updates 2026-09-16 19:08:46 -03:00
3f7f6c988d Merge branch 'rig-work' 2026-09-16 14:21:29 -03:00
3e864aa919 clean up rig 2026-09-16 14:21:21 -03:00
c3fe4422f2 makefile standalone 2026-09-16 13:52:04 -03:00
99b1988504 updated contract 2026-09-16 13:44:02 -03:00
5219bd5edb Merge branch 'ui' 2026-09-16 09:38:08 -03:00
f7910bf42b ui framework extraction updates 2026-09-16 09:38:04 -03:00
24aeadde83 dataconvert updates 2026-09-16 09:33:03 -03:00
5391f50755 Merge branch 'rig-work' 2026-09-16 09:31:36 -03:00
fed5d92034 rig updates 2026-09-16 09:31:13 -03:00
7b70b7edc6 adapter updates 2026-09-16 09:13:47 -03:00
74e246e67a dataconvert updates 2026-09-16 09:07:07 -03:00
e05f8f1fca dataconvert updates 2026-09-16 08:57:43 -03:00
b102ab8de7 Merge branch 'docgen-graphgen' 2026-09-16 08:20:35 -03:00
2f3e9c2634 dataconvert: spreadsheets to SQL seeds, layouts from config, row cap, SCHEMA.md
Converts CSV, xlsx/xls/ods, directories, ZIPs and globs into one INSERT
file per table. Producer-specific layouts (header/data rows found by a
marker cell) and sheet naming live in a gitignored dataconvert.json, with
dataconvert-example.json as the template. --max-rows samples each table
and SCHEMA.md records columns, types, row counts and full sizes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 08:23:38 -03:00
86d051da48 add project, change cli file names, use config 2026-09-16 08:17:29 -03:00
6ce24586bd Merge branch 'berth' 2026-09-16 07:06:06 -03:00
7242b09e3a distill update 2026-09-16 05:27:53 -03:00
fbf47980d9 Merge branch 'rig-work' 2026-09-16 04:53:04 -03:00
b9238040a6 rig dep update 2026-09-16 04:52:12 -03:00
aba696df79 feature matching for clean extraction 2026-09-14 16:00:27 -03:00
974679a432 berth updates 2026-09-14 07:28:50 -03:00
26f99265ca Merge branch 'rig-work' 2026-09-14 07:12:19 -03:00
a29e0708e8 new conventions 2026-09-14 06:13:22 -03:00
a9df70cde0 berth init 2026-09-14 03:57:00 -03:00
e0426ecb01 self tests 2026-09-13 21:57:26 -03:00
37c4d588ea Merge branch 'main' into docgen-graphgen 2026-09-13 21:41:29 -03:00
160ee31b8c docgen: drop the superseded first pass, port the style harvester
The IR supersedes both intermediate designs (requirements D6, D7):
station/tools/docgen and the graph model that briefly lived in graphgen.
Shipping them beside atlas2/docgen would mean two graph models, which is the
thing the architecture argues against.

Carried across rather than lost:
  - style/extract.py and tokens.py, the offline theme harvester (R31). Rewritten
    to emit a *theme* — a slot-to-hex binding — rather than a whole style file,
    since what harvesting recovers is which colour a slot should be, not what a
    kind should look like.
  - graphgen/README.md, rewritten for what graphgen actually is now: the
    schema explorer. It fixes the blank station-index entry at run.py:304.

Two bugs found while doing it:
  - Canvas and ink are the two lightness extremes, not the two most common
    values. In a Graphviz SVG every label carries a fill, so the ink outnumbers
    the canvas 87 to 43 and the old rule produced a theme whose text was
    invisible against its own background.
  - A name defined in both branches of an if/else produced a duplicate id, which
    failed validation on docgen's own source. Disambiguated by line.
2026-09-13 21:41:29 -03:00
358b98f826 site emitter 2026-09-12 07:08:00 -03:00
7cb892ccfe add cases for code, dbs, and outline notebook generation 2026-09-12 06:50:05 -03:00
542d704da4 docgen iter 2 2026-09-12 06:42:49 -03:00
49a9f8ee57 sanitized rig 2026-09-12 02:44:40 -03:00
966f8fc821 attemp to develop rig in spr without an actual use case 2026-08-26 07:27:12 -03:00
83b6cbebe3 init rig 2026-08-20 11:24:42 -03:00
a65c92257d Stop build.py sweeping secrets and bytecode into gen/
gen/<room>/ is the docker build context and soleprint/Dockerfile is `COPY . .`,
so anything reaching gen/ reaches an image layer — and registry.mcrn.ar is
public-read. station/tools/tester/.env has been gitignored since the last
incident, but .gitignore does not bind shutil: copy_path() called
shutil.copytree() with no ignore=, so the key was copied into every built room.
Verified extractable from soleprint_localtest-soleprint:latest (built 8 days
ago) at /app/station/tools/tester/.env.

ctrl/deploy.sh's --exclude='.env' is why this looked handled; it only covers the
rsync path, not the build-and-push path.

Two layers now:
  - copy_path()/merge_into() filter .env, __pycache__, *.pyc, .git, node_modules
    and virtualenvs out of bulk directory copies. Single-file copies named by a
    caller are untouched, so cfg/<room>/.env.example still ships.
  - soleprint/.dockerignore repeats the rule at the docker boundary and is
    copied into the context beside the Dockerfile. Follows the convention
    soleprint/atlas/.dockerignore already set (.env, .env.*, !.env.example).

Runtime is unaffected: no Dockerfile COPYs a .env, and the room compose files
supply it with `env_file: - .env`, read from the host at run time.

The key itself still needs rotating — it remains in git history.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 02:59:26 -03:00
78595f1bd9 Declare pass-through words PHONY in the Makefile
`make build ctrl` ran the build and then printed "make: 'ctrl' is up to date."
The empty rule from $(eval $(ARGS):;@:) is not enough when the word names a real
directory — and cfg, ctrl, docs, gen and init all exist at this level. Only
.PHONY stops make consulting the filesystem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 02:55:06 -03:00
9bcd439266 Normalise line endings to LF
spr had no .gitattributes at all, despite shipping ctrl/*.sh and generating
gen/<room>/ctrl/*.sh. A checkout on Windows/WSL rewrites those to CRLF, and a
shell script with CRLF fails as `bad interpreter: /usr/bin/env bash^M` — which
reads as a broken installer rather than a line-ending problem.

Copied verbatim from rig/.gitattributes and deliberately duplicated rather than
shared: rig/ has to carry its own so it survives being handed over alone.

No tracked file in either repo currently has CRLF, so `git add --renormalize .`
rewrote nothing. Landing it now, while that is true, keeps it off the diff of
whatever lands next.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 02:55:06 -03:00
74f03566f1 Ignore client rigs at the repo root, before rig's tree lands
A rig is a copy of rig/ renamed after the environment it models, so its k8s
files spell out a real architecture — the one thing that must not be committed
here. rig/.gitignore already refuses them, but only within rig/: a copy is a
SIBLING of rig/, where that file has no reach. spr had no rule at all, so the
first `git add -A` after the fold would have committed one.

Anchored at the root, and the negation names the full path because `*-rig/` is
unanchored and would otherwise match rig/sample-rig too.

Verified both ways: a file under client-rig/ is ignored, one under
rig/sample-rig/ is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 02:54:45 -03:00
2d9bc9289c updates 33.2 112 2026-08-11 07:30:27 -03:00
292 changed files with 42905 additions and 479 deletions

27
.gitattributes vendored Normal file
View File

@@ -0,0 +1,27 @@
# Copied verbatim from rig/.gitattributes, and deliberately duplicated rather
# than shared: rig/ must carry its own so it survives being handed over on its
# own, and spr had none at all despite shipping ctrl/*.sh and generating
# gen/<room>/ctrl/*.sh.
#
# 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

8
.gitignore vendored
View File

@@ -34,3 +34,11 @@ cfg/amar/
cfg/dlt/ cfg/dlt/
# Add new rooms here as they are created # Add new rooms here as they are created
# cfg/<room>/ # cfg/<room>/
# Client rigs. A rig is a copy of rig/ renamed after the environment it models,
# so its k8s files spell out a real architecture — exactly the thing that must
# not land here. They are versioned in their own repo.
#
# Anchored at the ROOT on purpose: a copy is a SIBLING of rig/, so a rule inside
# rig/.gitignore cannot see it.
*-rig/

View File

@@ -129,7 +129,7 @@ Every script stays runnable on its own — the standalone rule holds:
```bash ```bash
python build.py --cfg amar # -> gen/amar/ python build.py --cfg amar # -> gen/amar/
cd gen/standalone && python run.py # bare-metal cd gen/standalone && python run.py # bare-metal
./ctrl/kind-up.sh # still works directly ./ctrl/cluster.sh up # still runs directly; rig builds the cluster
cd gen/<room> && ./ctrl/start.sh # each room owns its lifecycle scripts cd gen/<room> && ./ctrl/start.sh # each room owns its lifecycle scripts
``` ```

View File

@@ -13,7 +13,7 @@
# make component ARGS="publish soleprint-ui /tmp/out --dist" # make component ARGS="publish soleprint-ui /tmp/out --dist"
# make deploy ARGS="--build" # make deploy ARGS="--build"
# #
# Every script stays runnable on its own (./ctrl/kind-up.sh still works, and each # Every script stays runnable on its own (./ctrl/cluster.sh up still works, and each
# built room keeps its own gen/<room>/ctrl/*.sh) — the standalone rule holds, and # built room keeps its own gen/<room>/ctrl/*.sh) — the standalone rule holds, and
# this only saves typing. # this only saves typing.
# #
@@ -28,11 +28,21 @@ export PYTHON
# treat them as goals of their own, so each gets a no-op rule. # treat them as goals of their own, so each gets a no-op rule.
ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
ifneq ($(ARGS),) ifneq ($(ARGS),)
# Declares each extra word as a target that does nothing: `:` is an empty rule
# body and `@` silences it. Without this, `make build sample` runs the build and
# then fails with "No rule to make target 'sample'", because make reads every
# word on the line as something it has been asked to build.
$(eval $(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 endif
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
.PHONY: help build start stop cluster deploy component .PHONY: help build start stop dist theme docs cluster deploy component
help: ## list targets help: ## list targets
@grep -hE '^[a-z]+:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16 @grep -hE '^[a-z]+:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16
@@ -48,6 +58,19 @@ start: ## run a built room [<room>] [-d] [--build]
stop: ## stop a running room [<room>] stop: ## stop a running room [<room>]
bash ctrl/stop.sh $(or $(ARGS),$(ROOM)) bash ctrl/stop.sh $(or $(ARGS),$(ROOM))
dist: ## compile the plexus UIs to single files [<room>]
bash ctrl/dist.sh $(or $(ARGS),$(ROOM))
# ── theme ──────────────────────────────────────────────────────────────────
theme: ## ad-hoc pages [new|parts|bake|check|export|run FILE]
bash ctrl/theme.sh $(or $(ARGS),bake)
# ── docs ───────────────────────────────────────────────────────────────────
docs: ## documentation [serve [port]|graphs [theme]] (default serve)
bash ctrl/docs.sh $(or $(ARGS),serve)
# ── cluster ──────────────────────────────────────────────────────────────── # ── cluster ────────────────────────────────────────────────────────────────
cluster: ## shared kind cluster [up|down|status] (default status) cluster: ## shared kind cluster [up|down|status] (default status)

21
berth/.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# Machine-local config and credentials. Never committed.
ctrl/.env
# Rendered output. Regenerate with `make services render <target>`.
# Generated config is an artifact, not source — the estate file is the source.
#
# The path is ctrl/render/out/, NOT render/out/. A pattern containing a slash is
# anchored to the directory holding this .gitignore, so `render/out/` would mean
# berth/render/out/ — which does not exist, and the real output would have been
# committed. Caught by `git check-ignore -v`, which is the only way to be sure.
ctrl/render/out/
# The "default" scratch bucket: always gitignored, never versioned.
def/
# Key material. NEVER committed.
#
# Anchored to ctrl/ for the same reason as render/out/ above: a pattern with a
# slash resolves against this file's own directory. `git check-ignore -v` is the
# only way to confirm it, and ctrl/vpn.sh refuses to write a key until it does.
ctrl/.secrets/

78
berth/Makefile Normal file
View File

@@ -0,0 +1,78 @@
# One target per ctrl/ script; the subcommand is an argument, not a second
# target: `make estate show`, not `make estate-show`. The logic lives in the
# scripts, never here.
#
# Config layers, weakest first: ctrl/versions.env < ctrl/env.d/<target>.env <
# ctrl/.env < the environment. So `make estate plan TARGET=gcp` beats all.
#
# Every target defaults to its READ-ONLY verb, and the verbs that change a live
# estate are not reachable by a bare word. Rationale: README.md.
ESTATE := $(or $(shell sed -n 's/^ESTATE=//p' ctrl/.env 2>/dev/null),$(shell ls estate/*.json 2>/dev/null | head -1 | xargs -r basename | sed 's/\.json$$//'))
TARGET := $(or $(shell sed -n 's/^TARGET=//p' ctrl/.env 2>/dev/null),aws)
.PHONY: help check selftest estate services vpn dns certs host ports registry docs
help: ## list targets
@grep -hE '^[a-z][a-z-]*:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16
# ── preflight ──────────────────────────────────────────────────────────────
check: ## is this estate coherent? reports, never fixes
bash ctrl/check.sh
selftest: ## does berth still do what it says? exits 1 if not
bash ctrl/selftest.sh
ports: ## port map [show|verify] (default show)
bash ctrl/ports.sh $(or $(ARGS),show)
# ── the estate ─────────────────────────────────────────────────────────────
estate: ## the estate [show|list|plan|apply|destroy] (default show)
bash ctrl/estate.sh $(or $(ARGS),show)
services: ## gateway routes [list|render <target>|deploy] (default list)
bash ctrl/services.sh $(or $(ARGS),list)
# ── the network ────────────────────────────────────────────────────────────
vpn: ## overlays [list|show <ov>|check|render|keygen] (default list)
bash ctrl/vpn.sh $(or $(ARGS),list)
# ── names and trust ────────────────────────────────────────────────────────
dns: ## DNS records [list|add|add-wildcard|remove] (default list)
bash ctrl/dns.sh $(or $(ARGS),list)
certs: ## TLS [status|verify|renew|push] (default status)
bash ctrl/certs.sh $(or $(ARGS),status)
# ── the box ────────────────────────────────────────────────────────────────
host: ## the remote box [status|ports|services] (default status)
bash ctrl/host.sh $(or $(ARGS),status)
registry: ## the image registry [status] (default status)
bash ctrl/registry.sh $(or $(ARGS),status)
# ── docs ───────────────────────────────────────────────────────────────────
docs: ## documentation [serve|graphs] (default serve)
bash ctrl/docs.sh $(or $(ARGS),serve)
# ── swallowing the argument words — MUST BE LAST IN THIS FILE ──────────────
#
# Words after the target are arguments, but make reads each as a goal, so each
# gets a no-op rule. This block must come AFTER the real targets: when an
# argument names one (`make host ports`, `make vpn check`), the last definition
# wins, and it has to be the no-op. With it first, make ran both scripts.
#
# Make's "overriding recipe" warning is the swallow working as intended.
ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
ifneq ($(ARGS),)
$(eval $(ARGS):;@:)
# .PHONY too: some of those words name real directories (ctrl, estate, render),
# and make treats an existing directory as already built.
.PHONY: $(ARGS)
endif

250
berth/README.md Normal file
View File

@@ -0,0 +1,250 @@
# berth
spr's deploy half. A rig is a mobile installation; a **berth** is the allocated, paid-for
place where it is moored and actually operates. Local rig → remote berth.
```bash
make check # is this estate coherent? reports, never fixes
make estate show # the description, resolved
make vpn check # the overlay: addresses, routing, bindings, key hygiene
make services render aws
make ports verify # does the local map still agree with rig?
```
`rm -rf berth/` is the uninstall.
---
## What berth is
**One description of an estate, with a swappable executor.** The description is the artifact;
the tool that runs it is a rendering.
| layer | what berth uses | note |
| --- | --- | --- |
| overlay | **WireGuard**, config generated per peer | GPL-2.0, in-kernel, and **no coordination server** |
| infra | **OpenTofu** — one executor, not a pair | plain `.tf`; `terraform` works identically |
| gateway | Caddy locally, nginx on the box | a projection with per-target rules, not a format conversion |
| pipeline | Woodpecker | Actions/GitLab reachable from the same description; not built |
The estate's facts — domain, hosts, ports, instance size, firewall rules, services — live in
**one file every rendering reads** (`estate/<name>.json`), rather than being restated in
one tool's language and again in another's. **Swappability is bought by the description, not
by maintaining two renderings** — a rendering you *can* produce, not one you *must* keep in
step. Two live renderings cost you every resource twice, forever, with nothing enforcing that
they agree.
**The seam belongs in a script, not in a tool.** A tool's schema is a ceiling you do not
control.
*(This once leaned on a specific precedent, since withdrawn — `✖ B2` in [STALE.md](STALE.md).)*
**OpenTofu, and only OpenTofu.** Terraform has been BUSL-licensed since 2023; OpenTofu is the
CLI-compatible MPL-2.0 fork (Linux Foundation). Write plain `.tf` that runs under both; the
scripts say `tofu`, and `terraform` works identically.
**Pipelines are the second executor axis, and are not built.** Woodpecker is the
self-hosted rendering; Actions and GitLab CI are the standards that must be reachable from
the same description. Named here so the infra seam is not designed in a way that forecloses
it.
---
## The overlay is berth's network layer
Two instances in different clouds cannot share a VPC. A WireGuard overlay gives them one flat
address space that berth owns and can reproduce on any provider — and, under a flaky
environment, a second layer beneath whatever the provider offers.
That inverts the usual cloud pattern. Instead of a VPC with security groups and private
subnets, each instance gets a public IP, opens **only** the WireGuard port, and carries
everything else inside the tunnel. **The security boundary moves out of the provider's VPC
and into a layer that is identical on AWS, on GCP, and on a laptop behind NAT.**
**Plain WireGuard, not Tailscale/Headscale/NetBird.** Tailscale's client is open but its
coordination plane is proprietary SaaS; Headscale and NetBird are open but add a control plane
to run. Plain WireGuard needs no server at all.
**The cost is real and accepted:** no NAT traversal, no relay, no peer discovery. A peer behind
NAT must dial one with a public endpoint. Fine here — the instances have public IPs and the dev
box roams — but two roaming peers cannot reach each other. That is why `PersistentKeepalive` is
a checked invariant rather than a detail.
**Keys never enter the description.** Private keys are generated on the peer that owns them
(`make vpn keygen`) into `ctrl/.secrets/` and injected only at render time; public keys live in
the estate, because a config cannot be built without them. `vpn.sh` **refuses to write** either
a key or a rendered config until `git check-ignore` confirms the path is ignored — this repo
has already been bitten once by a `.gitignore` pattern anchoring to the wrong directory.
This also decides something about the IaC layer: **OpenTofu must never generate a WireGuard
private key**, because Terraform-lineage state stores every resource attribute in plaintext.
---
## Two rules that are not style preferences
### 1. Every default is the read-only verb
```
make estate -> show make certs -> status
make dns -> list make host -> status
make estate apply / destroy -> print the plan, then refuse without --yes
```
rig's `make cluster` defaults to `up`, because every rig verb is safe — a kind cluster is
disposable. berth's are not: `tofu destroy` costs money and takes live DNS with it. **A
tool where every verb is safe must not grow verbs that are not.**
The failure this prevents is not hypothetical. `ppl/ctrl/certs.sh:42` is `CMD="${1:-all}"`,
so a bare `./ctrl/certs.sh` there issues a real Let's Encrypt cert, rsyncs it to the gateway,
and reloads nginx. berth's `certs` defaults to `status`.
### 2. berth and rig share a convention, not code
Neither imports the other. They match on **shape** — the `make <noun> <verb>` dispatch, the
four-source config layering, the key names — and consistency is verified by
**recomputation**: `make ports verify` recomputes rig's `20000 + (cksum(name) % 200) * 10`
to check the local map, rather than sourcing rig's `lib/config.sh`.
Copying three stable lines is the whole cost of not coupling them. A shared library would
put something outside `rig/` on rig's path, and rig's promise is that
`grep -rIn -iE 'soleprint|\bspr\b'` across it returns nothing.
**rig is also unaware that berth exists.** `ppl/local/Caddyfile` is berth's to generate; rig
must not reference `local.ar` — its handover scrub refuses the string.
---
## The gateway doctrine
Practised across this codebase for a long time and never written down, so: written down.
- **Caddy where routing is dynamic and config-driven** — the in-cluster gateway that
multiplexes by Host header, and the host-side `.local.ar` name→port map.
- **nginx where it is a static server or a plain long-running compose service on the box.**
- **Envoy in `mpr`** — a deliberate one-off, not a third pattern.
- **`ingress-nginx` only as a kind addon** — a different thing again from either gateway.
### Rendering is a projection, not a format conversion
The local Caddyfile and the box's nginx are not two spellings of the same content:
| | local (Caddy) | cloud (nginx) |
| --- | --- | --- |
| granularity | one file | one file per vhost |
| blocks per service | one | two (`:80` redirect + `:443` server) |
| TLS | none; every address needs an explicit `:80` | one shared wildcard cert |
| upstream | `localhost:<port>` | container name + `resolver 127.0.0.11` |
| name depth | free | constrained by the cert |
| ambiguity | most specific wins | exact, else `default_server` (= load order) |
The `:80` is not decoration: without it Caddy 2 defaults each site to `:443` with auto-HTTPS,
which on `*.local.ar` means cert provisioning that fails and breaks the listener. The
variable upstream is not decoration either: naming the upstream in a variable forces runtime
DNS resolution, so nginx **starts when the upstream container is absent** — which is what
lets one nginx front a dozen independent compose stacks.
Because the two disambiguate by **opposite** rules, a name set that is unambiguous locally
can be ambiguous on the box. `make check` asserts against the projection, not the source.
### Installing generated vhosts — an order that is not optional
1. Generated config lands in `conf.d/generated/`, **not** `conf.d/`. `ppl/ctrl/deploy.sh`
rsyncs with `--delete`; sharing a directory means one set gets erased.
2. `nginx.conf` needs a **third** include line — its `conf.d/*.conf` glob does not recurse,
which is why `conf.d/soleprint/*.conf` already needs its own.
3. That include changes **load order**, and load order decides which `:443` block catches
unmatched names. So `default.conf`'s commented-out `:443 default_server` must be restored
**first**. `make check` fails on it deliberately: it is a gate, not a warning.
---
## What the checks assert, and why
The scripts are deliberately thin on comment — the reasoning lives here. Every check below
corresponds to something that is wrong, or was wrong, in a real estate.
### `make check` — the estate
| assertion | the failure it catches |
| --- | --- |
| every service name is covered by an issued cert SAN | **a wildcard matches exactly one label.** `*.d.com` covers `a.d.com` but not `a.b.d.com`, which needs its own SAN. Surfaces otherwise as a browser TLS warning, far from its cause |
| a `:443 default_server` exists | DNS and the cert are wildcard but nginx matches `server_name` exactly, so without one the fallback for any unknown name is whichever vhost loads first — alphabetically, by accident |
| firewall rules and listeners agree | a rule allowing a port nothing listens on is **dead config**; a service no compose file declares is **undocumented state**. Neither is visible from one side alone, which is why the inventory has two halves |
| `HOST` is an ssh alias, never a hostname | there is no `Host <domain>` block, so a bare hostname falls through to the global defaults, ssh offers every key in the agent in turn, and `MaxAuthTries` (6) trips with *"Too many authentication failures"* before reaching the right one. The aliases set `IdentitiesOnly yes` |
### `make vpn check` — the overlay
| assertion | the failure it catches |
| --- | --- |
| peer addresses unique and inside the subnet | a duplicate is a silent misroute, never an error |
| AllowedIPs do not overlap | AllowedIPs is **cryptokey routing** — the route table and the ACL at once. Overlapping ranges resolve to the last match, so an overlap is both a misroute and an unintended grant |
| something carries `PersistentKeepalive` if anything roams | without it a NAT mapping expires and the tunnel works only while traffic flows outward — *"works sometimes"*, the hardest failure to read. Note it is **not** a property of the roaming peer's own entry: the roaming machine sets it on the entry for the peer it **dials** |
| the listen port is in the firewall | otherwise no peer can be dialed at all |
| no private key in the description | public keys are *also* 44-char base64, so the shape proves nothing. The real assertions are **no field named `priv*`** and **no key outside a `public_key` field** |
| overlay-reached services bind a reachable address | **a tunnel cannot reach loopback.** A service on `127.0.0.1` is unreachable over the overlay; one on `0.0.0.0` is reachable but also exposed to the whole LAN |
### Capturing the overlay
```bash
sudo wg show | make vpn capture --write
```
`wg show` has three forms and **only the first is safe**:
| form | safe | why |
| --- | --- | --- |
| `wg show` | **yes** | prints `private key: (hidden)` |
| `wg show <if> dump` | **no** | field 1 of the first line *is* the private key |
| `wg showconf <if>` | **no** | prints `PrivateKey=` outright |
`capture` refuses the latter two by shape. It matches peers by **allowed-ips address, not
public key** — the keys are exactly what is missing at that point — and **drops a roaming
peer's endpoint in the parser**, since that value is a home ISP address and a roaming peer
has no stable endpoint anyway.
## Layout
```
berth/
├── Makefile one target per ctrl/ script; the verb is an argument
├── STALE.md withdrawn assumptions, each with a check that runs
├── estate/<name>.json THE ARTIFACT — one description, many renderings
└── ctrl/
├── check.sh reports and instructs; never fixes
├── estate.sh show | list | plan | apply --yes | destroy --yes
├── services.sh list | render <aws|gcp|local> | deploy
├── ports.sh show | verify (the rig coincidence check)
├── dns.sh certs.sh host.sh registry.sh docs.sh
├── versions.env pinned toolchain (weakest layer)
├── env.d/<target>.env provider shape: aws | gcp
├── .env.example -> ctrl/.env, machine-local (gitignored)
├── lib/config.sh the four-layer load, from rig
├── lib/estate.sh reading and projecting the estate
└── render/*.tmpl nginx vhost shapes, substituted with sed
```
Config layers, weakest first: `versions.env``env.d/<target>.env``ctrl/.env` → the
caller's environment. So `make estate plan TARGET=gcp` beats everything.
**Identity is explicit — the inversion of rig.** rig derives its name from its folder so that
copies never collide. berth refuses to guess, because a deployment has exactly one production
and a wrong guess acts on the wrong estate. `ESTATE` names a file; the only convenience is
that a single `estate/*.json` is used without being asked for.
**`python3`, not `jq`.** rig ships a pinned static `jq` because its floor is "docker and
nothing else" on a machine it does not control. berth's floor is already higher, so
`python3` is a dependency it *has* rather than one it *adds* — the same reasoning by which
rig chose `sed` over `envsubst`.
---
## Status
**B0 (this) is the shape.** `estate/mcrn.json` is marked `UNVERIFIED`: it records what the
repos *claim*, because `ppl/infra/` was written and never applied — no `~/.pulumi`, no
`venv`, no stack state, files dated `mar 6`. B1's read-only inventory is what replaces those
claims with observations. Until then, `estate plan` and `estate apply` refuse: there is
nothing truthful to compare against yet.
`make check` currently fails on two real things — see `def/plans/36.0/berth.md`.

104
berth/STALE.md Normal file
View File

@@ -0,0 +1,104 @@
# berth — withdrawn assumptions
**Everything in this file is no longer true.**
It exists so the live docs stay short and so a withdrawn assumption cannot quietly return:
each entry carries a **check**, and `ctrl/selftest.sh` runs every one of them. A retraction
that is only prose is a retraction nobody re-reads.
Kept rather than deleted for the reason the docgen thread already wrote down —
*a requirement that disappears without explanation comes back.*
### For agents
- **Do not restate these** in a plan, a README or a comment. One line pointing here is enough.
- **Ids are stable.** Cite `✖ B3`; do not re-explain it.
- **When you withdraw an assumption, move it here** — quoted claim, where it came from, what
superseded it and why, what changed in the code, and a check that proves it is gone.
- **Only withdrawn things belong here.** A warning that is still actionable is a live rule,
however historical it sounds, and stays where it is.
---
**✖ B1 — "Pulumi is the source in spr, and Terraform must match the config."**
*(INDEX §5, carried from 35.3)* Withdrawn 2026-09-12. berth uses **OpenTofu and only
OpenTofu**. The rule assumed *open source* and *industry standard* pull apart — Terraform
being BUSL, Pulumi being the open alternative. OpenTofu is both: the standard language, plain
Terraform-compatible HCL, under MPL-2.0. Swappability was never bought by keeping two
renderings; it is bought by `estate/*.json` being the artifact — a rendering you *can*
produce, not one you *must* maintain.
**Gone from:** `ctrl/versions.env` (no `PULUMI_VERSION`), `ctrl/estate.sh` (`plan` runs one
executor), `ctrl/lib/config.sh` (`PULUMI_STACK``TOFU_WORKSPACE`), `ctrl/check.sh`
(toolchain list).
**Deliberately kept:** `README.md` and `estate/mcrn.json` both record that `ppl/infra/` was
written and never applied — *"no `~/.pulumi`, no venv, no stack state"*. That is a historical
fact about the estate, not a live dependency.
**Check:** no `pulumi` in `ctrl/` or `Makefile`.
**✖ B2 — "ctlptl's rejection is the precedent for *the seam belongs in a script, not a tool*."**
*(INDEX §5, `berth/README.md`)* Withdrawn 2026-09-12. **ctlptl was reinstated** — pinned in
`rig/ctrl/versions.env` at v0.9.4 — and had been removed for the wrong reason. The rule may
still hold; it now has to stand on its own reasoning rather than that example.
**Gone from:** `README.md` — the argument is stated directly, with no borrowed evidence.
**Check:** `ctlptl` appears nowhere in berth.
**✖ B3 — "`wg show` is safe; `wg showconf` is not."** *(my own note, 2026-09-12)* Incomplete,
and the gap is the dangerous one. There are **three** forms, and `wg show <if> dump` puts the
**private key in field 1 of the first line**. Stated as a two-way distinction, the `dump` form
reads as safe.
**Now:** `wg show` plain is safe; `dump` and `showconf` are not. `ctrl/vpn.sh capture` refuses
the latter two **by shape**, rather than parsing around them.
**Check:** `vpn.sh` names all three forms, and `capture` rejects both unsafe ones.
**✖ B4 — "A roaming peer must set `PersistentKeepalive` on its own entry."**
*(`ctrl/vpn.sh`, first draft)* Wrong side, and wrong in the direction that looks fine:
`PersistentKeepalive` is set per-peer in a config, so the roaming machine sets it on the entry
for the peer it **dials**. The original check would have **warned on a correctly configured
overlay**.
**Now:** checked once per overlay — if anything roams, some peer entry must carry a keepalive.
**Check:** the invariant is not keyed on the roaming peer's own `keepalive` field.
**✖ B5 — "The Makefile's pass-through block goes near the top, with the other variables."**
*(`Makefile`, inherited from rig's layout)* Withdrawn 2026-09-12. When a subcommand **names a
real target**, make has two recipes for it and the **last definition wins** — so with the block
first, `make host ports` ran `ctrl/host.sh ports` *and* `ctrl/ports.sh ports`, the second
failing because `ports` is not one of its verbs. Same for `make host services`, `make vpn
check`, `make vpn show estate`.
**Now:** the `$(eval $(ARGS):;@:)` block is **last in the file**, so the no-op wins and the
word is swallowed — which is what an argument is. Make's *"overriding recipe"* warning is the
swallow working.
**Check:** every colliding invocation dispatches to exactly one script.
**✖ B6 — "A base64 key in the description can be caught by its shape."**
*(`ctrl/vpn.sh check`, first draft)* WireGuard **public** keys are also 44-char base64 and
legitimately live in the estate, so shape alone proves nothing and would flag correct data.
**Now:** two assertions instead — **no field named `priv*`**, and **no base64 key outside a
`public_key` field**.
**Check:** the estate's public keys do not trip the secret check.
**✖ B7 — "`network.wireguard` is where the overlay is described."** *(`estate/mcrn.json`)*
Superseded 2026-09-12: WireGuard is berth's network layer, not one service's transport, so it
is a top-level `vpn` block with named overlays and peers. `ctrl/check.sh` and
`ctrl/registry.sh` were repointed.
**Gone from:** the estate schema — a `wireguard_moved` tombstone marks the old key.
**Check:** nothing reads `network.wireguard.*`.
**✖ B8 — "berth and rig are related through their first uses."** *(early framing)* Withdrawn:
**rig and berth are peers — neither depends on the other.** They match on shape (dispatch,
config layering, key names) and consistency is verified by **recomputation**, never by
dependency. A shared library would put something outside `rig/` on rig's path.
**Check:** berth imports nothing from rig; `ports.sh` recomputes the port formula and agrees
with rig's golden values.
**✖ B9 — "`langfuse.mcrn.ar` is an exception that cannot be generated."**
*(`estate/mcrn.json`, `raw: true`)* Withdrawn 2026-09-14. It was filed as the one route a
template could not express — a static `upstream{}` to a WireGuard address, with no `resolver`
and no `set $var`. It was not an exception; it was **the first instance of the general case**.
Those three properties are not three decisions, they are one: *this service is reached by
address on the overlay, not by name on the docker network.* Naming that decision —
`placement` — makes the file renderable.
**Gone from:** `estate/mcrn.json``lng` and `langfuse` were **two entries for one socket**
and are now one service with `placement: local`, `peer: nrft`, and a `local_host` for the
name it answers to locally. `raw` is dropped.
**Check:** the generated vhost matches the hand-written one, normalised for comments and
whitespace — proof against a live route rather than an assertion.

18
berth/ctrl/.env.example Normal file
View File

@@ -0,0 +1,18 @@
# Machine-local config. Copy to ctrl/.env (gitignored) and edit.
#
# The estate's FACTS live in estate/<name>.json.
# The provider's SHAPE lives in ctrl/env.d/<target>.env.
# This file is only what differs between machines, plus credentials.
# ESTATE is required when estate/ holds more than one file.
# ESTATE=mcrn
# TARGET=aws
# ssh ALIASES, never hostnames — check.sh refuses a value containing a dot.
# HOST=mcrn # app user, no sudo
# HOST_ADMIN=mcrn-admin # sudo, only where genuinely required
# Credentials: names only, never values. The secrets stay in ~/.aws and
# ~/.config/gcloud where their own tooling manages them.
# AWS_PROFILE=default
# GCP_PROJECT=

58
berth/ctrl/certs.sh Normal file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# The wildcard TLS cert for the gateway.
#
# Usage:
# ./certs.sh status # SANs issued vs SANs the services need
# ./certs.sh verify # inspect the cert served on :443
# ./certs.sh renew # refuses: issues a real cert
# ./certs.sh push # refuses: ships to a live gateway
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
source ./lib/estate.sh
load_config
status() {
local issued; issued=$(estate_get "certs.issued" | python3 -c 'import json,sys
try: print("\n".join(json.load(sys.stdin)))
except Exception: pass')
echo "issued SANs (estate/${ESTATE}.json: certs.issued):"
echo "$issued" | sed 's/^/ /'
echo
echo "SANs the services NEED (derived from services[]):"
estate_sans | sed 's/^/ /'
echo
local missing=0 s
while IFS= read -r s; do
[ -z "$s" ] && continue
grep -qxF "$s" <<< "$issued" || { echo "MISSING: $s"; missing=1; }
done < <(estate_sans)
[ "$missing" = 0 ] && echo "the issued cert covers every derived name."
echo
echo "certbot image: $(eval echo "\$$CERTBOT_IMAGE_VAR") provider: $DNS_PROVIDER"
}
verify() {
echo "would run:"
echo " echo | openssl s_client -connect ${DOMAIN}:443 -servername ${DOMAIN} 2>/dev/null \\"
echo " | openssl x509 -noout -dates -ext subjectAltName"
echo
echo "read-only against a live host — announce and approve first (§7)."
}
refuse() {
echo "REFUSING: '$1' acts on a live cert and a live gateway." >&2
echo " renew issues a real Let's Encrypt cert (rate-limited)." >&2
echo " push rsyncs to the gateway and reloads nginx." >&2
echo " Neither runs without explicit approval." >&2
exit 1
}
case "${1:-status}" in
status) status ;;
verify) verify ;;
renew|push) refuse "$1" ;;
*) echo "usage: $0 [status|verify|renew|push]" >&2; exit 1 ;;
esac

153
berth/ctrl/check.sh Normal file
View File

@@ -0,0 +1,153 @@
#!/usr/bin/env bash
# Is this estate coherent? Reports and instructs; never fixes.
#
# Takes no subcommand — there is one question to ask.
# Every check corresponds to something wrong in the estate today.
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
source ./lib/estate.sh
load_config
WORST=0
note() { echo " $*"; }
warn() { echo " WARN $*"; [ "$WORST" -lt 1 ] && WORST=1; return 0; }
bad() { echo " FAIL $*"; WORST=2; return 0; }
echo "estate: $ESTATE target: $TARGET domain: $DOMAIN"
echo
# 1. cert coverage: a wildcard matches exactly one label.
echo "certs — does the cert cover every name the services serve?"
issued=$(estate_get "certs.issued" | python3 -c 'import json,sys
try: print("\n".join(json.load(sys.stdin)))
except Exception: pass')
if [ -z "$issued" ]; then
warn "no certs.issued in the estate file — cannot check coverage."
else
while IFS=$'\x1f' read -r name host up kind raw placement peer port lhost; do
[ -z "$host" ] && continue
fqdn="${host}.${DOMAIN}"
# A '*' host stands for "any single label here" — check the deepest
# name it can produce, which is the one that fails.
probe="$fqdn"
case "$host" in \*.*) probe="anyroom.${host#\*.}.${DOMAIN}" ;; esac
covered=""
while IFS= read -r san; do
[ -z "$san" ] && continue
if san_covers "$probe" "$san"; then covered=1; break; fi
done <<< "$issued"
if [ -z "$covered" ]; then
bad "$name: '$probe' is covered by NO issued SAN"
note " issued: $(echo "$issued" | tr '\n' ' ')"
note " a wildcard matches exactly ONE label — reissue with"
note " -d '*.${host#\*.}.${DOMAIN}' or move the name one level up"
fi
done < <(estate_services "$TARGET")
[ "$WORST" -lt 2 ] && note "every service name is covered."
fi
echo
# 2. unmatched names: DNS and the cert are wildcard, nginx is exact, so
# without a :443 default_server the fallback is whichever vhost loads first.
echo "gateway — is there a deliberate answer for unmatched names?"
DEFAULT_CONF="${PPL_DIR:-$HOME/wdir/semester/ppl}/gateway/nginx/conf.d/default.conf"
if [ ! -f "$DEFAULT_CONF" ]; then
note "ppl not on this machine at $DEFAULT_CONF — skipped."
elif grep -qE '^\s*listen\s+443.*default_server' "$DEFAULT_CONF"; then
note "default.conf has a :443 default_server."
else
bad "default.conf has NO :443 default_server."
note " Unmatched names fall through to the first-loaded vhost."
note " This is a PREREQUISITE for generating any config: adding a"
note " generated include changes load order, and load order is what"
note " currently decides the fallback."
fi
echo
# 3. a rule allowing a port nothing listens on is dead config; a service no
# compose file declares is undocumented state. Needs both halves to see.
echo "firewall — rules against listeners"
estate_get "firewall" | python3 -c '
import json,sys
try: fw = json.load(sys.stdin)
except Exception: fw = []
for r in fw:
n = r.get("note")
print(" %-6s %-5s %s" % (r["port"], r.get("proto","tcp"), r.get("desc","")))
if n: print(" UNRESOLVED: " + n)
'
note "listener side: unknown until captured (ss -ltnp over ssh $HOST)."
# Ask the structure, not the prose: the condition is "does a peer still lack a
# public key", not "is there a _status string". _status is ALWAYS non-empty —
# capture rewrites it to "CAPTURED ..." — so testing it for emptiness pinned
# this warning on permanently, including after the capture it asks for.
uncaptured=$(estate_get "vpn.overlays.estate.peers" 2>/dev/null | python3 -c '
import json, sys
try:
peers = json.load(sys.stdin)
except Exception:
sys.exit(0)
print(" ".join(n for n, p in peers.items() if not p.get("public_key")))
' 2>/dev/null)
if [ -n "$uncaptured" ]; then
warn "overlay: public keys not captured for:$uncaptured — see 'make vpn check'"
note " 10.8.0.1 carries the registry and woodpecker gRPC;"
note " 10.8.0.2 backs langfuse. Nothing in the tree creates the"
note " interface — a fresh box cannot start the gateway compose."
note " capture with: sudo wg show | make vpn capture --write"
else
note "overlay: $(estate_get 'vpn._status')"
fi
note "overlay detail: make vpn show estate"
echo
# 4. ssh aliases, never hostnames: a bare hostname offers every agent key and
# trips MaxAuthTries before reaching the right one.
echo "ssh — aliases, never hostnames"
for var in HOST HOST_ADMIN; do
val="${!var:-}"
if [ -z "$val" ]; then
warn "$var is unset."
elif [[ "$val" == *.* ]]; then
bad "$var='$val' looks like a hostname, not a ~/.ssh/config alias."
note " A bare hostname trips MaxAuthTries before reaching the key."
elif [ -f "$HOME/.ssh/config" ] && grep -qiE "^\s*Host\s+.*\b${val}\b" "$HOME/.ssh/config"; then
note "$var=$val — Host block present."
else
warn "$var='$val' has no matching Host block in ~/.ssh/config."
fi
done
for f in "$HOME/wdir/semester/ppl/ctrl/.env"; do
[ -f "$f" ] || continue
if grep -qE '^SERVER=.*\.' "$f"; then
warn "$f sets SERVER to a hostname, not an alias — every ppl script inherits it."
fi
done
echo
# ── 5. toolchain ───────────────────────────────────────────────────────────
echo "toolchain"
for t in python3 "$TOFU_BIN" aws gcloud ssh rsync wg; do
if command -v "$t" >/dev/null 2>&1; then
note "$(printf '%-8s' "$t") present"
else
note "$(printf '%-8s' "$t") MISSING — $(case $t in
tofu) echo 'blocks: estate plan/apply; terraform works identically' ;;
wg) echo 'blocks: vpn keygen and the overlay checks' ;;
aws) echo 'blocks: the control-plane inventory, dns on route53' ;;
gcloud) echo 'blocks: the gcp estate' ;;
*) echo 'blocks: most things' ;;
esac)"
fi
done
echo
case "$WORST" in
0) echo "OK" ;;
1) echo "OK, with warnings" ;;
2) echo "PROBLEMS FOUND — see FAIL lines above" ;;
esac
exit 0

81
berth/ctrl/dns.sh Normal file
View File

@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# DNS records, over whichever provider the target names.
#
# Usage:
# ./dns.sh list
# ./dns.sh add <subdomain> # <sub>.<domain> -> <domain>
# ./dns.sh add-wildcard <sub>
# ./dns.sh remove <subdomain>
#
# Read-only verbs announce and wait; mutating ones refuse.
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
source ./lib/estate.sh
load_config
announce() {
echo "would run:"
printf ' %s\n' "$*"
}
list() {
case "$DNS_PROVIDER" in
route53)
announce "aws route53 list-resource-record-sets --hosted-zone-id $AWS_HOSTED_ZONE_ID" \
"--query 'ResourceRecordSets[].[Name,Type,TTL,ResourceRecords[0].Value]' --output table"
;;
google)
announce "gcloud dns record-sets list --zone=${ESTATE}-zone --project=${GCP_PROJECT:-<unset>}"
;;
*) echo "unknown DNS_PROVIDER: $DNS_PROVIDER" >&2; exit 1 ;;
esac
echo
echo "read-only, but NOT run: each batch is announced and"
echo "waited on. Approve it and it runs."
}
mutate() {
local verb="$1" sub="${2:-}"
[ -z "$sub" ] && { echo "usage: $0 $verb <subdomain>" >&2; exit 1; }
local name
case "$verb" in
add) name="${sub}.${DOMAIN}" ;;
add-wildcard) name="*.${sub}.${DOMAIN}" ;;
remove) name="${sub}.${DOMAIN}" ;;
esac
echo "$verb: $name -> $DOMAIN (provider: $DNS_PROVIDER)"
echo
# The wildcard-depth rule again, applied BEFORE the record is created rather
# than discovered in a browser afterwards.
if [ "$verb" = "add" ]; then
local issued; issued=$(estate_get "certs.issued" | python3 -c 'import json,sys
try: print("\n".join(json.load(sys.stdin)))
except Exception: pass')
local covered=""
while IFS= read -r san; do
[ -z "$san" ] && continue
san_covers "$name" "$san" && { covered=1; break; }
done <<< "$issued"
[ -z "$covered" ] && {
echo "WARNING: '$name' is covered by no issued SAN." >&2
echo " A wildcard matches ONE label. The record would" >&2
echo " resolve and then fail TLS. Reissue the cert first." >&2
echo >&2
}
fi
echo "REFUSING: this changes live DNS." >&2
echo " Nothing is created, modified or deleted on any account without" >&2
echo " explicit approval for that specific action." >&2
exit 1
}
case "${1:-list}" in
list) list ;;
add|add-wildcard|remove) mutate "$@" ;;
*) echo "usage: $0 [list|add <sub>|add-wildcard <sub>|remove <sub>]" >&2; exit 1 ;;
esac

27
berth/ctrl/docs.sh Normal file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Documentation.
#
# Usage:
# ./docs.sh serve|graphs
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
source ./lib/estate.sh
load_config
case "${1:-serve}" in
serve)
echo "berth has no doc server yet — the README is the documentation."
echo " $(cd .. && pwd)/README.md"
echo
echo "the estate, resolved: make estate show"
;;
graphs)
echo "not implemented. The estate file is the graph's source; a"
echo "renderer belongs with docgen/graphgen, which is another thread's"
echo "another thread's — so this is a handoff, not a stub to fill in here."
;;
*) echo "usage: $0 [serve|graphs]" >&2; exit 1 ;;
esac

14
berth/ctrl/env.d/aws.env Normal file
View File

@@ -0,0 +1,14 @@
# Target: AWS — the estate that actually runs today (mcrn.ar).
TARGET_NAME=aws
CLOUD=aws
REGION=us-east-1
INSTANCE_TYPE=t3.small
# The Route53 hosted zone. It ALREADY EXISTS and is reused, never created —
# see estate.sh's refusal to create a zone on this target.
AWS_HOSTED_ZONE_ID=Z02279903503ZMIB5FC1N
SSH_KEY_NAME=mcrn
# certbot's DNS-01 plugin for this provider.
CERTBOT_IMAGE_VAR=CERTBOT_AWS_IMAGE
DNS_PROVIDER=route53

17
berth/ctrl/env.d/gcp.env Normal file
View File

@@ -0,0 +1,17 @@
# Target: GCP — the replica, on its own domain.
#
# The domain differs from AWS's on purpose: this target CREATES a DNS zone
# where aws reuses one, so a shared domain would create a second authoritative
# zone and break live DNS. The domain lives in estate/nrft.json, not here.
TARGET_NAME=gcp
CLOUD=gcp
REGION=us-central1
INSTANCE_TYPE=e2-small
GCP_PROJECT=
GCP_ZONE=us-central1-a
# Delegation order: create the zone and let it answer first, then set the
# nameservers at the registrar — it validates that they respond.
CERTBOT_IMAGE_VAR=CERTBOT_GCP_IMAGE
DNS_PROVIDER=google

101
berth/ctrl/estate.sh Normal file
View File

@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# The estate: what it is, what would change, and — behind a gate — changing it.
#
# Usage:
# ./estate.sh # show
# ./estate.sh list # every estate/*.json
# ./estate.sh plan # tofu plan, read-only
# ./estate.sh apply --yes # refuses without --yes
# ./estate.sh destroy --yes
#
# Why the default is read-only: ../README.md
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
source ./lib/estate.sh
load_config
show() {
echo "estate: $ESTATE ($ESTATE_FILE)"
echo "target: $TARGET (cloud=$CLOUD region=$REGION)"
echo "domain: $DOMAIN"
echo "host: $HOST (sudo: $HOST_ADMIN)"
echo "workspace: $TOFU_WORKSPACE"
echo
local status; status=$(estate_get "_meta.status")
[ -n "$status" ] && echo " !! $status" && echo
echo "services in scope for '$TARGET':"
local name host up kind raw
while IFS=$'\x1f' read -r name host up kind raw placement peer port lhost; do
[ -z "$name" ] && continue
printf ' %-12s %-14s %-24s %s%s\n' \
"$name" "${host:--}" "${up:--}" "$kind" \
"$([ -n "$raw" ] && echo ' [hand-written]')"
done < <(estate_services "$TARGET")
echo
echo "cert SANs (derived, not listed):"
estate_sans | sed 's/^/ /'
}
list() {
local f n
printf '%-12s %-16s %s\n' ESTATE DOMAIN STATUS
for f in ../estate/*.json; do
[ -f "$f" ] || continue
n=$(basename "$f" .json)
printf '%-12s %-16s %s%s\n' "$n" \
"$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("domain",""))' "$f")" \
"$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("_meta",{}).get("status",""))' "$f")" \
"$([ "$n" = "$ESTATE" ] && echo ' <- this one')"
done
}
# Read-only. Meaningful only once state is imported: against empty state,
# plan reports "create N resources", which is not drift.
plan() {
echo "== $TOFU_BIN plan =="
if ! command -v "$TOFU_BIN" >/dev/null; then
echo " $TOFU_BIN not installed — skipped." >&2
echo " OpenTofu is the MPL-2.0 fork; 'terraform' works identically." >&2
else
echo " would run: $TOFU_BIN plan -var-file=<(estate)"
fi
echo
echo "NOTE: not wired up yet, and that is the point. tofu plan against empty"
echo " state reports \"create N resources\" — which is not drift, it is an"
echo " empty state. It becomes the check that proves the description"
echo " matches reality only once state is IMPORTED from the inventory."
echo " ppl/infra/ describes an aspiration: it was never applied."
}
# The gate. Two things have to be true: --yes present, AND the plan shown first.
require_yes() {
local verb="$1"; shift
local yes=""
for a in "$@"; do [ "$a" = "--yes" ] && yes=1; done
if [ -z "$yes" ]; then
echo "refusing to $verb without --yes." >&2
echo >&2
echo " $verb changes a live, billable estate and can take DNS with it." >&2
echo " Read the plan first: make estate plan" >&2
echo " Then: ./ctrl/estate.sh $verb --yes" >&2
exit 1
fi
echo "refusing to $verb: not implemented, and deliberately so." >&2
echo " The executor is not wired up, and nothing is imported yet, so" >&2
echo " there is nothing truthful to apply." >&2
exit 1
}
case "${1:-show}" in
show) show ;;
list) list ;;
plan) plan ;;
apply) shift; require_yes apply "$@" ;;
destroy) shift; require_yes destroy "$@" ;;
*) echo "usage: $0 [show|list|plan|apply --yes|destroy --yes]" >&2; exit 1 ;;
esac

63
berth/ctrl/host.sh Normal file
View File

@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# The remote box — the other half of the inventory.
#
# Usage:
# ./host.sh status|ports|services
#
# Announces what it would run over `ssh $HOST` and does not run it. Always the
# ssh alias, never a hostname: there is no `Host mcrn.ar` block, so a bare
# hostname offers every agent key and trips MaxAuthTries.
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
source ./lib/estate.sh
load_config
guard_alias() {
if [[ "$HOST" == *.* ]]; then
echo "HOST='$HOST' is a hostname, not a ~/.ssh/config alias. Refusing." >&2
exit 1
fi
}
announce_batch() {
echo "would run, over 'ssh $HOST':"
printf ' %s\n' "$@"
echo
echo "read-only, and NOT run. Announce-first applies to EACH batch, not"
echo "once per session — so the commands can be read and"
echo "learned rather than scrolled past."
}
guard_alias
case "${1:-status}" in
status)
announce_batch \
"uname -a; uptime; df -h /" \
"docker ps --format '{{.Names}}\t{{.Image}}\t{{.Ports}}'" \
"docker network inspect gateway --format '{{range .Containers}}{{.Name}} {{end}}'" \
"systemctl list-units --type=service --state=running --no-pager" \
"systemctl list-timers --no-pager"
echo
echo "sudo-only, over 'ssh $HOST_ADMIN' and only where genuinely needed:"
echo " wg show # the WireGuard peers that exist nowhere in the tree"
;;
ports)
announce_batch "ss -ltnp"
echo "the estate declares these firewall rules:"
estate_get "firewall" | python3 -c '
import json,sys
for r in json.load(sys.stdin):
print(" %-6s %-5s %s" % (r["port"], r.get("proto","tcp"), r.get("desc","")))'
;;
services)
announce_batch "docker compose -f ~/ppl/gateway/docker-compose.yml ps"
echo "the estate declares $(estate_services "$TARGET" | grep -c . ) service(s) for target '$TARGET'."
echo "the gateway compose declares 8. The difference is sibling repos'"
echo "stacks joining the shared 'gateway' network — intended design, but"
echo "nothing in the tree lists it. The inventory produces that list."
;;
*) echo "usage: $0 [status|ports|services]" >&2; exit 1 ;;
esac

94
berth/ctrl/lib/config.sh Normal file
View File

@@ -0,0 +1,94 @@
# Shared config loading. Sourced, never executed. Run from ctrl/.
#
# Precedence, weakest first:
# ctrl/versions.env pinned toolchain (committed)
# ctrl/env.d/<target>.env provider shape: aws|gcp (committed)
# ctrl/.env machine-local + secrets (gitignored)
# the caller's env `make estate plan TARGET=gcp` (always wins)
CONFIG_OVERRIDABLE="TARGET ESTATE CLOUD REGION INSTANCE_TYPE
HOST HOST_ADMIN AWS_PROFILE AWS_HOSTED_ZONE_ID
GCP_PROJECT GCP_ZONE TOFU_WORKSPACE"
# rig's port formula, reproduced rather than imported. cksum because it is
# POSIX and gives the same value on every machine. Used to verify, not allocate.
derive_port_base() {
local h; h=$(printf '%s' "$1" | cksum | awk '{print $1}')
echo $((20000 + (h % 200) * 10))
}
_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
}
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 TARGET is the caller's before we pick the file.
_config_restore "$saved"
local target="${TARGET:-aws}"
if [ ! -f "./env.d/${target}.env" ]; then
echo "no such target: env.d/${target}.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/${target}.env"
[ -f ./.env ] && source ./.env
set +a
_config_restore "$saved"
TARGET="$target"
# Identity is explicit: berth never guesses which estate it is acting on.
# The one convenience: a single estate/*.json is used without being asked.
if [ -z "${ESTATE:-}" ]; then
local n; n=$(ls ../estate/*.json 2>/dev/null | wc -l)
if [ "$n" = "1" ]; then
ESTATE=$(basename "$(ls ../estate/*.json)" .json)
else
echo "ESTATE is not set and estate/ holds $n candidates — refusing to guess." >&2
echo "available: $(ls ../estate/*.json 2>/dev/null | xargs -n1 basename | sed 's/\.json$//' | tr '\n' ' ')" >&2
echo "set it: make estate show ESTATE=<name>, or ESTATE= in ctrl/.env" >&2
exit 1
fi
fi
ESTATE_FILE="../estate/${ESTATE}.json"
if [ ! -f "$ESTATE_FILE" ]; then
echo "no such estate: estate/${ESTATE}.json" >&2
echo "available: $(ls ../estate/*.json 2>/dev/null | xargs -n1 basename | sed 's/\.json$//' | tr '\n' ' ')" >&2
exit 1
fi
# Facts come from the estate file, never restated in a target env.
DOMAIN=$(estate_get "domain")
HOST="${HOST:-$(estate_get "host")}"
HOST_ADMIN="${HOST_ADMIN:-$(estate_get "host_admin")}"
# Workspace == target, so the two can never mean different things.
TOFU_WORKSPACE="${TOFU_WORKSPACE:-$TARGET}"
}

147
berth/ctrl/lib/estate.sh Normal file
View File

@@ -0,0 +1,147 @@
# Reading and projecting estate/<name>.json. Sourced, never executed.
#
# python3 rather than jq: berth's floor already includes python3, so it is a
# dependency berth has rather than one it adds.
estate_get() {
python3 -c '
import json, sys
d = json.load(open(sys.argv[1]))
for k in sys.argv[2].split("."):
if isinstance(d, list):
try: k = int(k)
except ValueError: sys.exit(0)
try: d = d[k]
except Exception: sys.exit(0)
print("" if d is None else d if isinstance(d, str) else json.dumps(d))
' "$ESTATE_FILE" "$1"
}
# Services in scope for one target. A service names its targets; absent = all.
# Fields are US-separated (0x1f), not tab: tab is IFS whitespace, so bash
# collapses a run of them and an empty field would shift every later column.
estate_services() {
local target="${1:-$TARGET}"
python3 -c '
import json, sys
d = json.load(open(sys.argv[1]))
target = sys.argv[2]
for s in d.get("services", []):
tg = s.get("targets")
if tg is not None and target not in tg:
continue
print("\x1f".join([
s.get("name", ""),
s.get("host", ""),
str(s.get(target + "_upstream", s.get("upstream", "")) or ""),
s.get("kind", "proxy"),
"raw" if s.get("raw") else "",
s.get("placement", "box"),
s.get("peer", ""),
str(s.get("port", "") or ""),
s.get("local_host", s.get("host", "")),
]))
' "$ESTATE_FILE" "$target"
}
# The SAN list the services need, derived — never a literal list.
estate_sans() {
python3 -c '
import json, sys
d = json.load(open(sys.argv[1]))
domain = d["domain"]
sans = [domain]
depths = set()
for s in d.get("services", []):
h = s.get("host", "")
if not h:
continue
# A wildcard matches exactly ONE label. "git" needs *.domain; "dlt.spr"
# needs *.spr.domain. The parent of the leaf is what has to be covered.
parent = h.split(".", 1)[1] if "." in h else ""
depths.add(parent)
for p in sorted(depths):
sans.append("*." + (p + "." if p else "") + domain)
for s in sans:
print(s)
' "$ESTATE_FILE"
}
# Is <fqdn> covered by <san>? A wildcard matches exactly one label.
san_covers() {
local fqdn="$1" san="$2"
[ "$fqdn" = "$san" ] && return 0
case "$san" in
\*.*)
local suffix="${san#\*.}"
# Must end in .suffix AND have exactly one extra label.
case "$fqdn" in
*".$suffix") [ "${fqdn%".$suffix"}" = "${fqdn%%.*}" ] && return 0 ;;
esac
;;
esac
return 1
}
# ── the overlay ────────────────────────────────────────────────────────────
# Every overlay name, one per line.
overlay_names() {
python3 -c '
import json, sys
d = json.load(open(sys.argv[1]))
for n in d.get("vpn", {}).get("overlays", {}):
print(n)
' "$ESTATE_FILE"
}
# Peers of one overlay, US-separated:
# name, address, role, endpoint, public_key, allowed_ips, keepalive
overlay_peers() {
python3 -c '
import json, sys
d = json.load(open(sys.argv[1]))
ov = d.get("vpn", {}).get("overlays", {}).get(sys.argv[2], {})
for name, p in ov.get("peers", {}).items():
print("\x1f".join(str(x) if x is not None else "" for x in [
name, p.get("address"), p.get("role"), p.get("endpoint"),
p.get("public_key"), p.get("allowed_ips"), p.get("keepalive"),
]))
' "$ESTATE_FILE" "$1"
}
overlay_get() { estate_get "vpn.overlays.$1.$2"; }
# Is an address inside a CIDR? Pure python so there is no ipcalc dependency —
# berth's floor already includes python3 because the IaC side needs it.
addr_in_subnet() {
python3 -c '
import ipaddress, sys
try:
sys.exit(0 if ipaddress.ip_address(sys.argv[1]) in ipaddress.ip_network(sys.argv[2], strict=False) else 1)
except ValueError:
sys.exit(2)
' "$1" "$2"
}
# The upstream a service actually resolves to, as "host:port".
#
# A PLACED service has no literal `upstream` field: ✖ B9 replaced langfuse's
# hand-written `10.8.0.2:3000` with placement+peer+port, because being reached
# by address on the overlay is ONE decision, not three properties. Everything
# that asks "what does this service point at" must therefore resolve it the
# same way, or it silently sees an empty string and skips the service — which
# is exactly how vpn.sh's bindings invariant went quiet after B9 landed.
#
# usage: service_upstream <up> <placement> <peer> <port>
service_upstream() {
local up="$1" placement="$2" peer="$3" port="$4"
case "$placement" in
local|instance)
local addr; addr="$(overlay_get estate "peers.${peer}.address")"
[ -z "$addr" ] && return 1
printf '%s:%s' "$addr" "$port"
;;
*) printf '%s' "$up" ;;
esac
}

78
berth/ctrl/ports.sh Normal file
View File

@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# The local port map, and whether it still agrees with rig.
#
# Usage:
# ./ports.sh show # DERIVED / ACTIVE / SOURCE
# ./ports.sh verify # recompute rig's formula, report drift
#
# berth recomputes rig's port formula rather than importing it, so neither
# depends on the other. See ../README.md.
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
source ./lib/estate.sh
load_config
# name<US>host<US>local_port for everything that has one.
_local_ports() {
python3 -c '
import json, sys
d = json.load(open(sys.argv[1]))
for s in d.get("services", []):
p = s.get("local_port")
if p:
print("\x1f".join([s.get("name",""), s.get("host",""), str(p)]))
' "$ESTATE_FILE"
}
show() {
local ld; ld=$(estate_get "local_domain"); : "${ld:=local.ar}"
printf '%-12s %-22s %-8s %-8s %s\n' NAME ADDRESS ACTIVE DERIVED SOURCE
local name host port base
while IFS=$'\x1f' read -r name host port; do
[ -z "$name" ] && continue
base=$(derive_port_base "$host")
if [ "$port" = "$base" ]; then
printf '%-12s %-22s %-8s %-8s %s\n' "$name" "${host}.${ld}" "$port" "$base" "derived"
else
printf '%-12s %-22s %-8s %-8s %s\n' "$name" "${host}.${ld}" "$port" "$base" "override"
fi
done < <(_local_ports)
echo
echo "DERIVED is what rig's formula gives for that name. ACTIVE is what the"
echo "estate records. 'override' is not an error — most of these were never"
echo "rigs. 'make ports verify' says which ones should have matched."
}
verify() {
local name host port base rc=0 checked=0
while IFS=$'\x1f' read -r name host port; do
[ -z "$name" ] && continue
# Only 20000-21999 is rig's to predict; anything else was never derived.
if [ "$port" -lt 20000 ] || [ "$port" -gt 21999 ]; then
continue
fi
checked=$((checked + 1))
base=$(derive_port_base "$host")
if [ "$port" != "$base" ]; then
echo "DRIFT $name (${host}): estate says $port, rig's formula gives $base"
echo " either the rig pinned HTTP_PORT in its ctrl/.env, or the"
echo " folder was renamed. The Caddy map is stale either way."
rc=1
else
echo "ok $name (${host}): $port"
fi
done < <(_local_ports)
echo
echo "checked $checked rig-shaped port(s) in 20000-21999."
[ "$rc" = 0 ] && echo "no drift." || echo "drift found — regenerate with 'make services render local'."
return 0
}
case "${1:-show}" in
show) show ;;
verify) verify ;;
*) echo "usage: $0 [show|verify]" >&2; exit 1 ;;
esac

33
berth/ctrl/registry.sh Normal file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# The image registry — remote, and reachable only over the overlay.
#
# Usage:
# ./registry.sh status
#
# One verb: berth reports on a registry running on someone else's box. Starting
# and stopping it is that box's business.
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
source ./lib/estate.sh
load_config
status() {
local wg_server; wg_server=$(overlay_get estate "peers.box.address")
echo "registry: registry.${DOMAIN} (public pull via /v2/)"
echo "push: ${wg_server}:5000 (WireGuard-only, not in the firewall)"
echo
echo "would run:"
echo " curl -s https://registry.${DOMAIN}/v2/_catalog"
echo
echo "NOTE: the push endpoint binds ${wg_server}, and $(estate_get 'vpn._status')."
echo " A freshly-provisioned box cannot start the gateway compose file"
echo " at all, because that bind fails."
}
case "${1:-status}" in
status) status ;;
*) echo "usage: $0 [status]" >&2; exit 1 ;;
esac

View File

@@ -0,0 +1,32 @@
# ${NAME} — GENERATED by berth from estate/${ESTATE}.json. Do not edit.
# Edit the estate file and re-run: make services render aws
server {
listen 80;
server_name ${FQDN};
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name ${FQDN};
ssl_certificate /etc/nginx/certs/live/${DOMAIN}/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/live/${DOMAIN}/privkey.pem;
# Docker's embedded DNS. Naming the upstream in a VARIABLE forces runtime
# resolution, so nginx STARTS even when the upstream container is absent.
# With a literal proxy_pass, one stopped container takes the whole gateway
# down at reload — which is what makes one nginx able to front a dozen
# independent compose stacks.
resolver 127.0.0.11 valid=30s;
location / {
set $upstream_${NAME} ${UPSTREAM_HOST};
proxy_pass http://$upstream_${NAME}:${UPSTREAM_PORT};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@@ -0,0 +1,23 @@
# ${NAME} — GENERATED by berth from estate/${ESTATE}.json. Do not edit.
# Edit the estate file and re-run: make services render aws
server {
listen 80;
server_name ${FQDN};
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name ${FQDN};
ssl_certificate /etc/nginx/certs/live/${DOMAIN}/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/live/${DOMAIN}/privkey.pem;
root /usr/share/nginx/html/${NAME};
index index.html;
location / {
try_files $uri $uri/ =404;
}
}

View File

@@ -0,0 +1,32 @@
# ${NAME} — GENERATED by berth from estate/${ESTATE}.json. Do not edit.
# Placement: ${PLACEMENT} (${PEER}) — reached over the overlay, not the docker network.
upstream ${NAME}_backend {
server ${UPSTREAM_HOST}:${UPSTREAM_PORT};
}
server {
listen 80;
server_name ${FQDN};
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name ${FQDN};
ssl_certificate /etc/nginx/certs/live/${DOMAIN}/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/live/${DOMAIN}/privkey.pem;
# No `resolver`, and no `set $var` indirection — deliberately. Those exist so
# nginx starts when a CONTAINER is absent; this upstream is a literal address
# on the overlay, which needs no DNS at all. The three properties are one
# decision, and placement is what decides them.
location / {
proxy_pass http://${NAME}_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

210
berth/ctrl/selftest.sh Normal file
View File

@@ -0,0 +1,210 @@
#!/usr/bin/env bash
# What berth has settled, and what it has withdrawn, written down as assertions.
#
# Two halves:
# - decisions that hold. Failing one means "you are about to undo this".
# - every entry in ../STALE.md. Failing one means a withdrawn assumption came
# back. That is the half that makes STALE.md an audit surface and not an
# archive — a retraction nobody re-reads is a retraction that decays.
#
# Scope: no cloud, no ssh, no sudo, no network. Cheap enough to actually run.
# `make check` reports on the world and never fails; this exits 1, like rig's.
#
# Usage: make selftest (or: bash ctrl/selftest.sh)
set -uo pipefail # NOT -e: one failing check must not abort the rest
cd "$(dirname "$0")"
source ./lib/config.sh
rc=0
passed=0
check() { # name, expected, actual
if [ "$2" = "$3" ]; then
printf ' ok %s\n' "$1"
passed=$((passed + 1))
else
printf ' FAIL %s\n expected: %s\n got: %s\n' "$1" "$2" "$3"
rc=1
fi
}
note() { printf '\n%s\n' "$1"; }
skip() { printf ' skip %s (%s)\n' "$1" "$2"; }
# An absence check must not match the files that RECORD the absence. STALE.md
# names every withdrawn thing by definition, and this file names them again to
# assert them — so both are excluded, or every check fails on itself. rig hits
# the same wall and assembles its pattern from fragments for the same reason.
NOSELF="--exclude=selftest.sh --exclude=STALE.md"
absent() { grep -rIl $NOSELF "$@" 2>/dev/null | wc -l; }
# A throwaway estate, for the checks that have to run berth rather than read it.
TMP_ESTATE=_selftest
cleanup() { rm -f "../estate/${TMP_ESTATE}.json"; }
trap cleanup EXIT
note "the withdrawn assumptions — ../STALE.md, one check each"
# B1 — Pulumi. The two surviving mentions are historical fact about ppl/infra
# and live in README.md and the estate, not in anything that runs.
check "B1 no pulumi in the code" "0" "$(absent -i pulumi . ../Makefile)"
# B2 — the ctlptl precedent. Withdrawn; the argument stands on its own now.
check "B2 the withdrawn precedent is cited nowhere" "0" "$(absent -i ctlptl ..)"
# B3 — `wg show <if> dump` leaks the private key in field 1. Stating only
# show-vs-showconf makes the dump form read as safe.
check "B3 all three wg forms are named" "yes" \
"$(grep -q 'dump' vpn.sh && grep -q 'showconf' vpn.sh && echo yes || echo no)"
check "B3 capture refuses showconf-shaped input" "1" \
"$(printf '[Interface]\nPrivateKey = x\n' | bash vpn.sh capture >/dev/null 2>&1; echo $?)"
check "B3 capture refuses dump-shaped input" "1" \
"$(printf 'priv\tpub\t51820\toff\n' | bash vpn.sh capture >/dev/null 2>&1; echo $?)"
# B4 — keepalive belongs to the peer that DIALS, not the one that roams. The
# first version warned on a correctly configured overlay, so the check is run
# against one: hub carries the keepalive, nrft roams.
python3 - <<'PY'
import json, collections
d = json.load(open("../estate/mcrn.json"), object_pairs_hook=collections.OrderedDict)
d["vpn"]["overlays"]["estate"]["peers"]["box"]["keepalive"] = 25
json.dump(d, open("../estate/_selftest.json", "w"), indent=2, ensure_ascii=False)
PY
check "B4 a correct overlay raises no keepalive warning" "0" \
"$(ESTATE=$TMP_ESTATE bash vpn.sh check 2>/dev/null | grep -ci 'no peer entry carries')"
# B6 — public keys are 44-char base64 too, so shape alone would flag correct
# data. Same fixture, with a real-shaped public key on a peer.
python3 - <<'PY'
import base64, collections, json, os
d = json.load(open("../estate/_selftest.json"), object_pairs_hook=collections.OrderedDict)
d["vpn"]["overlays"]["estate"]["peers"]["box"]["public_key"] = base64.b64encode(os.urandom(32)).decode()
json.dump(d, open("../estate/_selftest.json", "w"), indent=2, ensure_ascii=False)
PY
check "B6 a public key does not trip the secret check" "0" \
"$(ESTATE=$TMP_ESTATE bash vpn.sh check 2>/dev/null | grep -c 'FAIL.*key')"
cleanup # the fixture is done with; two estate files would make load_config
# refuse to guess below, which is right but reads as a config failure
# B5 — the pass-through block must be LAST, or a subcommand that names a real
# target runs that target too. Checked through make, not by reading the file.
note "B5 a subcommand that names a target dispatches once"
for combo in "host ports" "host services" "vpn check" "vpn show estate" "estate show"; do
check " make $combo" "1" \
"$(cd .. && make -n $combo 2>/dev/null | grep -c 'bash ctrl/')"
done
# B7 — the overlay moved out of network.wireguard into a top-level vpn block.
check "B7 nothing reads network.wireguard" "0" "$(absent 'network\.wireguard' .)"
# B8 — peers, not relatives. berth sources nothing from rig.
check "B8 berth sources nothing from rig" "0" "$(absent -E 'rig/ctrl|\.\./rig' .)"
# B9 — langfuse was filed as an exception a template could not express. It was
# the general case. The proof is a live route: render it and diff against the
# hand-written file, normalised for comments and whitespace.
LIVE=/home/mariano/wdir/semester/ppl/gateway/nginx/conf.d/langfuse.conf
if [ -f "$LIVE" ]; then
norm() { sed -e 's/#.*//' -e 's/[[:space:]]\+/ /g' -e 's/^ //' -e 's/ $//' -e '/^$/d' "$1"; }
bash services.sh render aws >/dev/null 2>&1
check "B9 the generated vhost reproduces the live one" "same" \
"$(diff -q <(norm ./render/out/aws/langfuse.conf) <(norm "$LIVE") >/dev/null 2>&1 \
&& echo same || echo different)"
else
skip "B9 generated vhost matches the live one" "ppl not on this machine"
fi
note "the safety contract — berth's verbs are not all safe"
check "estate defaults to show" "show" "$(cd .. && make -n estate 2>/dev/null | grep -oE 'estate\.sh [a-z]+' | awk '{print $2}')"
check "certs defaults to status" "status" "$(cd .. && make -n certs 2>/dev/null | grep -oE 'certs\.sh [a-z]+' | awk '{print $2}')"
check "dns defaults to list" "list" "$(cd .. && make -n dns 2>/dev/null | grep -oE 'dns\.sh [a-z]+' | awk '{print $2}')"
check "vpn defaults to list" "list" "$(cd .. && make -n vpn 2>/dev/null | grep -oE 'vpn\.sh [a-z]+' | awk '{print $2}')"
for verb in apply destroy; do
check "estate $verb refuses without --yes" "1" \
"$(bash estate.sh "$verb" >/dev/null 2>&1; echo $?)"
done
for verb in renew push; do
check "certs $verb refuses" "1" \
"$(bash certs.sh "$verb" >/dev/null 2>&1; echo $?)"
done
check "dns add refuses to change live DNS" "1" \
"$(bash dns.sh add selftest >/dev/null 2>&1; echo $?)"
check "vpn up refuses without --yes" "1" \
"$(bash vpn.sh up >/dev/null 2>&1; echo $?)"
note "config — the caller's env beats the files"
# Generated from CONFIG_OVERRIDABLE, so a new key enrols itself.
test_value() {
case "$1" in
TARGET) echo "gcp" ;;
ESTATE) echo "mcrn" ;;
*) echo "selftest-sentinel" ;;
esac
}
for key in $CONFIG_OVERRIDABLE; do
want="$(test_value "$key")"
got="$(export "$key=$want"; load_config >/dev/null 2>&1; echo "${!key}")"
check " caller's $key wins" "$want" "$got"
done
note "rig agreement — recomputed, never imported"
# rig pins these same constants in its own selftest. Both arrive at them from
# the same formula with no shared code, which is the coupling rule made testable.
check "derive_port_base rig" "20310" "$(derive_port_base rig)"
check "derive_port_base foo" "21690" "$(derive_port_base foo)"
check "derive_port_base my-proj" "21030" "$(derive_port_base my-proj)"
note "containment — berth writes nothing outside berth/"
check "no tracked change outside berth/" "0" \
"$(cd ../.. && git status --porcelain 2>/dev/null | grep -vc '^.. berth/')"
check "generated output is ignored" "yes" \
"$(cd .. && git check-ignore -q ctrl/render/out && echo yes || echo no)"
# A trailing-slash pattern matches directories only, so ask about a path
# inside it rather than the (not-yet-existing) directory itself.
check "key material is ignored" "yes" \
"$(cd .. && git check-ignore -q ctrl/.secrets/vpn/any.key && echo yes || echo no)"
note "capture and the checks that read it — three bugs found by running, 2026-09-14"
# 1. A placed service's upstream is DERIVED (✖ B9). Anything reading the raw
# `upstream` field sees "" and skips it — which is how vpn.sh's bindings
# invariant, the "my configurations broke" detector, went quiet the day
# placement landed while still printing OK. Vacuous passes are the failure
# mode this whole file exists to catch.
check "a placed service resolves to a real upstream" "10.8.0.2:3000" \
"$(bash -c 'source ./lib/config.sh; source ./lib/estate.sh; load_config >/dev/null;
service_upstream "" local nrft 3000')"
check "bindings actually inspects a service" "1" \
"$(bash ./vpn.sh check 2>/dev/null | grep -c 'no service currently has an overlay address' \
| awk '{print 1-$1}')"
# 2. A listen port belongs to a PEER. The roaming peer's is an ephemeral source
# port; writing it to the overlay renames the port the firewall rule is
# checked against — silently, since both are plausible integers.
check "a roaming peer's port is not the overlay's port" "51820" \
"$(python3 -c 'import json;print(json.load(open("../estate/mcrn.json"))["vpn"]["overlays"]["estate"]["listen_port"])')"
# 3. _status is always non-empty — capture rewrites it rather than clearing it —
# so a warning gated on "is it set" can never turn off, including after the
# capture it asks for. Gate on the structure instead.
check "the capture warning clears once keys are in" "0" \
"$(bash ./check.sh 2>/dev/null | grep -c 'public keys not captured')"
note "every STALE entry has a check here"
# Not "$0": line 15 cd's into this script's directory, so a relative $0 no
# longer resolves. After the cd the file is simply selftest.sh.
# Ids are counted wherever they appear — B5's sits in a note(), not a check name.
entries="$(grep -c '^\*\*✖ B' ../STALE.md)"
checked="$(grep -oE '\bB[1-9][0-9]?\b' selftest.sh | sort -u | wc -l)"
check "STALE.md entries are all covered" "$entries" "$checked"
printf '\n%d passed' "$passed"
[ "$rc" -ne 0 ] && printf ', SOME FAILED'
printf '\n'
exit "$rc"

183
berth/ctrl/services.sh Normal file
View File

@@ -0,0 +1,183 @@
#!/usr/bin/env bash
# Gateway routes, projected from the estate onto one target.
#
# Usage:
# ./services.sh # list
# ./services.sh render aws # -> render/out/aws/*.conf (nginx vhosts)
# ./services.sh render local # -> render/out/local/Caddyfile
# ./services.sh deploy # refuses; ppl/ctrl/deploy.sh ships config
#
# Each target is a projection with its own rules, not a format conversion.
# The nine axes they disagree on, and the install order: ../README.md
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
source ./lib/estate.sh
load_config
OUT_ROOT="./render/out"
list() {
printf '%-12s %-16s %-22s %-9s %s\n' NAME FQDN UPSTREAM PLACEMENT SOURCE
local name host up kind raw
while IFS=$'\x1f' read -r name host up kind raw placement peer port lhost; do
[ -z "$name" ] && continue
# A placed service has no literal `upstream` — it is derived from the
# peer's overlay address, so show what it actually resolves to.
local shown; shown="$(service_upstream "$up" "$placement" "$peer" "$port")" || shown=""
printf '%-12s %-16s %-22s %-9s %s\n' \
"$name" "${host}.${DOMAIN}" "${shown:--}" "$placement" \
"$([ -n "$raw" ] && echo 'hand-written' || echo 'generated')"
done < <(estate_services "$TARGET")
echo
echo "hand-written entries are NOT generated and NOT overwritten."
echo "run 'make estate show' to see why each one is an exception."
}
render_cloud() {
# Two statements: `local a="$1" b="$a"` expands all arguments before any
# assignment, so $a would still be unset.
local target="$1"
local out="$OUT_ROOT/$target"
rm -rf "$out"; mkdir -p "$out"
local name host up kind raw uhost uport n=0 skipped=0
while IFS=$'\x1f' read -r name host up kind raw placement peer port lhost; do
[ -z "$name" ] && continue
if [ -n "$raw" ]; then
skipped=$((skipped + 1))
continue
fi
# Placement picks the rendering. A container on the estate's own network
# is reached by NAME through docker's resolver; anything on the overlay
# is reached by ADDRESS and needs no DNS. That is one decision, not the
# three properties (upstream{}, no resolver, no set $var) it produces.
local tmpl
case "$placement" in
local|instance)
tmpl=./render/nginx-upstream.tmpl
uhost="$(overlay_get estate "peers.${peer}.address")"
uport="$port" # resolved via service_upstream's same rule
if [ -z "$uhost" ]; then
echo " ! $name: placement '$placement' names peer '$peer', which has no address" >&2
continue
fi
;;
hosted)
echo " ! $name: placement 'hosted' is declared but not rendered yet" >&2
continue
;;
*)
if [ "$kind" = "static" ]; then
tmpl=./render/nginx-static.tmpl
uhost=""; uport=""
else
tmpl=./render/nginx-proxy.tmpl
uhost="${up%%:*}"; uport="${up##*:}"
fi
;;
esac
sed -e "s|\${NAME}|${name}|g" \
-e "s|\${ESTATE}|${ESTATE}|g" \
-e "s|\${FQDN}|${host}.${DOMAIN}|g" \
-e "s|\${DOMAIN}|${DOMAIN}|g" \
-e "s|\${PLACEMENT}|${placement}|g" \
-e "s|\${PEER}|${peer}|g" \
-e "s|\${UPSTREAM_HOST}|${uhost}|g" \
-e "s|\${UPSTREAM_PORT}|${uport}|g" \
"$tmpl" > "$out/${name}.conf"
n=$((n + 1))
done < <(estate_services "$target")
echo "wrote $n vhost(s) to $out/ ($skipped hand-written, left alone)"
cat <<EONOTE
TO INSTALL THESE, THREE THINGS MUST HAPPEN IN THIS ORDER — and the order is the
whole reason this is not a one-liner:
1. These land in conf.d/generated/, NOT conf.d/. ppl/ctrl/deploy.sh rsyncs the
gateway with --delete; generated and hand-written config sharing one
directory means one of them gets erased.
2. nginx.conf needs a THIRD include line. Its conf.d/*.conf glob does not
recurse — which is exactly why conf.d/soleprint/*.conf already needs its
own line at nginx.conf:28-30.
3. That new include changes LOAD ORDER, and load order decides which :443
block catches unmatched names. So default.conf's commented-out
':443 default_server' must be restored FIRST. 'make check' fails on this
today, deliberately — it is a gate, not a warning.
EONOTE
}
render_local() {
local out="$OUT_ROOT/local"; mkdir -p "$out"
local ld; ld=$(estate_get "local_domain")
: "${ld:=local.ar}"
local name host up kind raw port drift=0
{
cat <<EOH
# GENERATED by berth from estate/${ESTATE}.json. Do not edit.
# Regenerate: make services render local
#
# Install: sudo ln -sf \$PWD/Caddyfile /etc/caddy/Caddyfile && sudo systemctl reload caddy
# All *.${ld} resolve to 127.0.0.1 via dnsmasq.
#
# Every site address carries an explicit :80. Without it Caddy 2 defaults to
# :443 with auto-HTTPS, which on *.${ld} means cert provisioning attempts that
# fail and break the listener. Plain HTTP only on this host.
#
# Caddy matches the MOST SPECIFIC site address, not the first — the opposite of
# nginx, which matches exactly and otherwise falls to default_server. A name set
# that is unambiguous here can be ambiguous on the box.
EOH
while IFS=$'\x1f' read -r name host up kind raw placement peer port lhost; do
[ -z "$name" ] && continue
port=$(python3 -c '
import json,sys
d=json.load(open(sys.argv[1]))
for s in d.get("services",[]):
if s.get("name")==sys.argv[2]:
print(s.get("local_port") or ""); break
' "$ESTATE_FILE" "$name")
[ -z "$port" ] && continue
echo
echo "${lhost}.${ld}:80, *.${lhost}.${ld}:80 {"
echo " reverse_proxy localhost:${port}"
echo "}"
done < <(estate_services local)
} > "$out/Caddyfile"
echo "wrote $out/Caddyfile"
echo
echo "rig is not consulted and does not know this exists — its handover"
echo "scrub refuses the string '${ld}'. Where a port belongs to a rig,"
echo "'make ports verify' RECOMPUTES rig's formula to check it rather than"
echo "importing rig's code. Convention, verified; not a dependency."
}
case "${1:-list}" in
list) list ;;
render)
shift
# `case "${1:-X}"` defaults the match but leaves $1 empty.
t="${1:-$TARGET}"
case "$t" in
local) render_local ;;
aws|gcp) render_cloud "$t" ;;
*) echo "usage: $0 render [aws|gcp|local]" >&2; exit 1 ;;
esac
;;
deploy)
echo "berth does not ship config; ppl/ctrl/deploy.sh does." >&2
echo " berth's half is the DESCRIPTION and the render. Shipping is" >&2
echo " rsync + compose against a live box, and it belongs where the" >&2
echo " credentials are: berth is the tool, ppl is the estate that" >&2
echo " holds the secrets." >&2
exit 1
;;
*) echo "usage: $0 [list|render [aws|gcp|local]|deploy]" >&2; exit 1 ;;
esac

11
berth/ctrl/versions.env Normal file
View File

@@ -0,0 +1,11 @@
# Pinned toolchain. Committed. The weakest config layer.
# The infra executor. One, not a pair — see README.md.
# This pin is a placeholder; set it from `tofu version` once installed.
TOFU_VERSION=1.9.0
TOFU_BIN=tofu
# certbot runs as a throwaway container so the DNS plugin's credentials never
# have to be installed on this machine.
CERTBOT_AWS_IMAGE=certbot/dns-route53:latest
CERTBOT_GCP_IMAGE=certbot/dns-google:latest

478
berth/ctrl/vpn.sh Normal file
View File

@@ -0,0 +1,478 @@
#!/usr/bin/env bash
# Overlays — WireGuard as berth's network layer.
#
# Usage:
# ./vpn.sh # list
# ./vpn.sh show <overlay> # topology
# ./vpn.sh check # invariants
# ./vpn.sh render <peer> # peer's wg0.conf -> render/out/vpn/
# ./vpn.sh keygen <peer> # keypair -> .secrets/; prints only the public key
# ./vpn.sh up|down --yes # refuses; the host operates its own tunnel
#
# sudo wg show | ./vpn.sh capture [--write]
#
# `wg show` is the only safe form: `wg show <if> dump` puts the private key in
# field 1, and `wg showconf` prints it outright. capture refuses both.
#
# Rationale, topology and key handling: ../README.md
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
source ./lib/estate.sh
load_config
SECRETS_DIR="./.secrets/vpn"
OUT_DIR="./render/out/vpn"
WORST=0
note() { echo " $*"; }
warn() { echo " WARN $*"; [ "$WORST" -lt 1 ] && WORST=1; return 0; }
bad() { echo " FAIL $*"; WORST=2; return 0; }
# Which addresses belong to THIS machine, so checks can distinguish what they
# can actually see from what needs capturing elsewhere.
my_overlay_addrs() { ip -4 -o addr show 2>/dev/null | awk '{split($4,a,"/"); print a[1]}'; }
is_me() { my_overlay_addrs | grep -qxF "$1"; }
list() {
local n sub port peers
for n in $(overlay_names); do
sub=$(overlay_get "$n" subnet)
port=$(overlay_get "$n" listen_port)
peers=$(overlay_peers "$n" | grep -c . || true)
printf '%-10s %-16s port %-7s %s peer(s)\n' "$n" "$sub" "$port" "$peers"
note "$(overlay_get "$n" purpose)"
done
local st; st=$(estate_get "vpn._status")
[ -n "$st" ] && { echo; echo " !! $st"; }
}
show() {
local ov="${1:-}"
[ -z "$ov" ] && { echo "usage: $0 show <overlay>" >&2; exit 1; }
overlay_names | grep -qxF "$ov" || {
echo "no such overlay: $ov" >&2
echo "available: $(overlay_names | tr '\n' ' ')" >&2; exit 1; }
echo "overlay: $ov subnet $(overlay_get "$ov" subnet) udp/$(overlay_get "$ov" listen_port)"
echo
printf '%-8s %-12s %-9s %-22s %s\n' PEER ADDRESS ROLE ENDPOINT PUBKEY
local name addr role ep pk aips ka
while IFS=$'\x1f' read -r name addr role ep pk aips ka; do
[ -z "$name" ] && continue
printf '%-8s %-12s %-9s %-22s %s%s\n' \
"$name" "$addr" "$role" "${ep:-}" "${pk:-}" \
"$(is_me "$addr" && echo ' <- this machine')"
done < <(overlay_peers "$ov")
}
check() {
local ov name addr role ep pk aips ka
for ov in $(overlay_names); do
local sub port
sub=$(overlay_get "$ov" subnet); port=$(overlay_get "$ov" listen_port)
echo "overlay '$ov' — $sub udp/$port"
# 1. addresses: unique, and inside the subnet. Two peers sharing an
# address is a silent misroute, never an error message.
local addrs; addrs=$(overlay_peers "$ov" | cut -d$'\x1f' -f2 | grep -v '^$' || true)
local dupes; dupes=$(echo "$addrs" | sort | uniq -d)
[ -n "$dupes" ] && bad "duplicate peer addresses: $(echo "$dupes" | tr '\n' ' ')"
while IFS= read -r a; do
[ -z "$a" ] && continue
addr_in_subnet "$a" "$sub" || bad "$a is outside $sub"
done <<< "$addrs"
while IFS=$'\x1f' read -r name addr role ep pk aips ka; do
[ -z "$name" ] && continue
# AllowedIPs is cryptokey routing — route table and ACL at once.
case "$aips" in
*0.0.0.0/0*) warn "$name: AllowedIPs includes 0.0.0.0/0 — full-tunnel. Deliberate?" ;;
esac
# A peer with no endpoint cannot be dialed; it must initiate.
if [ -z "$ep" ] && [ "$role" != "roaming" ]; then
warn "$name: role '$role' but no endpoint — nothing can dial it."
fi
# Keepalive is NOT on the roaming peer's own entry — it is set on
# the entry for the peer it dials. Checked per-overlay below.
[ -z "$pk" ] && note "$name: public_key not captured yet"
done < <(overlay_peers "$ov")
# If anything roams, some peer entry must carry a keepalive.
if overlay_peers "$ov" | cut -d$'\x1f' -f3 | grep -qx roaming; then
if ! overlay_peers "$ov" | cut -d$'\x1f' -f7 | grep -qE '^[0-9]+$'; then
warn "a peer roams but no peer entry carries PersistentKeepalive"
note " the roaming side sets it on the entry for the peer it dials;"
note " without it the NAT mapping expires and the tunnel works only"
note " while traffic flows outward — 'works sometimes'"
else
note "keepalive present on the dialed peer."
fi
fi
# 4. the listen port must be open wherever a peer is dialable.
local fwports; fwports=$(estate_get "firewall" | python3 -c '
import json,sys
try: print(" ".join(str(r.get("port")) for r in json.load(sys.stdin)))
except Exception: pass')
case " $fwports " in
*" $port "*) note "udp/$port present in the firewall description." ;;
*) bad "udp/$port is in no firewall rule — no peer could be dialed." ;;
esac
echo
done
# Public keys are also 44-char base64, so shape alone proves nothing. The
# assertions are: no field named private, no key outside a public_key field.
echo "secrets — the description must never carry a private key"
local leaked
leaked=$(python3 - estate/../../estate/*.json <<'PY' 2>/dev/null || true
import json, re, sys, glob
KEY = re.compile(r'^[A-Za-z0-9+/]{43}=$')
bad = []
for f in glob.glob("../estate/*.json"):
def walk(node, path):
if isinstance(node, dict):
for k, v in node.items():
if re.search(r'priv', k, re.I):
bad.append(f"{f}: field '{'.'.join(path+[k])}' is named private")
walk(v, path + [k])
elif isinstance(node, list):
for i, v in enumerate(node): walk(v, path + [str(i)])
elif isinstance(node, str) and KEY.match(node):
if not path or 'public' not in path[-1]:
bad.append(f"{f}: base64 key at '{'.'.join(path)}' is not a public_key field")
walk(json.load(open(f)), [])
print("\n".join(bad))
PY
)
if [ -n "$leaked" ]; then
echo "$leaked" | while IFS= read -r l; do [ -n "$l" ] && bad "$l"; done
else
note "clean — no private-named field, no stray key material."
fi
echo
# A service reached over the overlay must bind an address the tunnel can
# reach. Loopback cannot be reached through a tunnel.
echo "bindings — services reached over the overlay must bind a reachable address"
local checked=0
while IFS=$'\x1f' read -r name host up kind raw placement peer port lhost; do
# Resolve placement first: a placed service's upstream is derived, not
# literal, so reading `up` alone skips it and this invariant goes quiet.
up="$(service_upstream "$up" "$placement" "$peer" "$port")" || true
[ -z "$up" ] && continue
local uhost="${up%%:*}" uport="${up##*:}"
addr_in_subnet "$uhost" "$(overlay_get estate subnet)" 2>/dev/null || continue
checked=$((checked + 1))
if is_me "$uhost"; then
local binds; binds=$(ss -ltn 2>/dev/null | awk -v p=":$uport\$" '$4 ~ p {print $4}')
if [ -z "$binds" ]; then
bad "$name: nothing listens on :$uport here, but $uhost:$uport is its upstream"
elif echo "$binds" | grep -q '^127\.0\.0\.1:'; then
bad "$name: :$uport binds 127.0.0.1 — unreachable over the overlay"
note " the tunnel cannot reach loopback; bind 0.0.0.0 or $uhost"
else
note "$name: :$uport binds $(echo "$binds" | tr '\n' ' ')— reachable"
echo "$binds" | grep -q '^0\.0\.0\.0:' && \
note " (0.0.0.0 also exposes it to the LAN; $uhost alone would be tighter)"
fi
else
note "$name: upstream $uhost is another peer — needs capture there"
fi
done < <(estate_services "$TARGET")
[ "$checked" = 0 ] && note "no service currently has an overlay address as its upstream."
echo
case "$WORST" in
0) echo "OK" ;;
1) echo "OK, with warnings" ;;
2) echo "PROBLEMS FOUND — see FAIL lines above" ;;
esac
return 0
}
# A .gitignore pattern containing a slash anchors to its own directory, so the
# only way to know a path is ignored is to ask git.
assert_ignored() {
local path="$1"
if ! git check-ignore -q "$path" 2>/dev/null; then
echo "REFUSING: '$path' is not gitignored." >&2
echo " Writing key material there would stage it on the next 'git add'." >&2
echo " Verify with: git check-ignore -v $path" >&2
exit 1
fi
}
keygen() {
local peer="${1:-}"
[ -z "$peer" ] && { echo "usage: $0 keygen <peer>" >&2; exit 1; }
command -v wg >/dev/null || { echo "wg not installed." >&2; exit 1; }
mkdir -p "$SECRETS_DIR"
assert_ignored "$SECRETS_DIR"
local kf="$SECRETS_DIR/${peer}.key"
[ -e "$kf" ] && { echo "REFUSING: $kf exists. Delete it deliberately to rotate." >&2; exit 1; }
( umask 077; wg genkey > "$kf" )
echo "private key -> $kf (0600, gitignored, never leaves this machine)"
echo
echo "public key for the estate description:"
echo " $(wg pubkey < "$kf")"
echo
echo "Paste that into estate/*.json under vpn.overlays.<ov>.peers.${peer}.public_key."
echo "The private key stays here and is injected only at render time."
}
render() {
local peer="${1:-}"
[ -z "$peer" ] && { echo "usage: $0 render <peer>" >&2; exit 1; }
mkdir -p "$OUT_DIR"
assert_ignored "$OUT_DIR"
local ov=estate
local found=""
local name addr role ep pk aips ka
while IFS=$'\x1f' read -r name addr role ep pk aips ka; do
[ "$name" = "$peer" ] && found=1 && break
done < <(overlay_peers "$ov")
[ -z "$found" ] && { echo "no such peer '$peer' in overlay '$ov'" >&2; exit 1; }
local missing=""
while IFS=$'\x1f' read -r name addr role ep pk aips ka; do
[ -z "$pk" ] && missing="$missing $name"
done < <(overlay_peers "$ov")
if [ -n "$missing" ]; then
echo "REFUSING to render: public keys not captured for:$missing" >&2
echo " A config without every peer's public key is a config that silently" >&2
echo " drops those peers. Capture first: sudo wg show | $0 capture --write" >&2
exit 1
fi
echo "would write $OUT_DIR/${peer}.conf (all keys present)"
}
# Reads `wg show` on stdin. Peers match by allowed-ips address, not public key,
# because the keys are what is missing. A roaming peer's endpoint is a home
# address and has no stable value — dropped in the parser, not just unused.
capture() {
local write="" as_peer=""
while [ $# -gt 0 ]; do
case "$1" in
--write) write=1 ;;
--as) shift; as_peer="${1:-}"
[ -z "$as_peer" ] && { echo "--as needs a peer name" >&2; exit 1; } ;;
*) echo "capture: unknown argument '$1'" >&2; exit 1 ;;
esac
shift
done
local input; input=$(cat)
if [ -z "$input" ]; then
echo "nothing on stdin." >&2
echo " run: sudo wg show | $0 capture" >&2
exit 1
fi
# Refuse the unsafe forms outright rather than parsing around them.
if printf '%s' "$input" | grep -qiE '^\s*PrivateKey\s*=|^\[Interface\]'; then
echo "REFUSING: this looks like 'wg showconf' output — it contains a PRIVATE KEY." >&2
echo " Use 'sudo wg show' (plain). It prints 'private key: (hidden)'." >&2
exit 1
fi
if ! printf '%s' "$input" | grep -q 'interface:'; then
echo "REFUSING: this does not look like 'wg show' output." >&2
echo " If it was 'wg show <if> dump': that form's first field IS the" >&2
echo " private key. Use 'sudo wg show' with no subcommand." >&2
exit 1
fi
WRITE="$write" AS_PEER="$as_peer" INPUT="$input" python3 - "$ESTATE_FILE" <<'PYCAP'
import collections, ipaddress, json, os, re, sys
text = os.environ["INPUT"]
write = os.environ.get("WRITE") == "1"
path = sys.argv[1]
iface, peers, cur = {}, [], None
for line in text.splitlines():
st = line.strip()
if st.startswith("interface:"):
cur = iface; cur["name"] = st.split(":", 1)[1].strip(); continue
if st.startswith("peer:"):
cur = {"public_key": st.split(":", 1)[1].strip()}; peers.append(cur); continue
if cur is None or ":" not in st:
continue
k, v = st.split(":", 1)
k, v = k.strip().lower(), v.strip()
if k == "private key":
continue # never recorded, whatever it says
if k == "public key": cur["public_key"] = v
elif k == "listening port": cur["listen_port"] = v
elif k == "allowed ips": cur["allowed_ips"] = v
elif k == "endpoint": cur["endpoint"] = v
elif k == "persistent keepalive":
m = re.search(r"(\d+)", v)
if m: cur["keepalive"] = int(m.group(1))
d = json.load(open(path), object_pairs_hook=collections.OrderedDict)
ov = d["vpn"]["overlays"]["estate"]
# address -> peer name, from what the estate already declares
by_addr = {p["address"]: n for n, p in ov["peers"].items() if p.get("address")}
by_key = {p["public_key"]: n for n, p in ov["peers"].items() if p.get("public_key")}
subnet = ipaddress.ip_network(ov["subnet"]) if ov.get("subnet") else None
hubs = [n for n, p in ov["peers"].items() if p.get("role") == "hub"]
# Whose interface block is this? `--as` names it explicitly, and that is the only
# thing that works for output captured over ssh: the addresses on THIS machine
# say nothing about the machine the output came from.
as_peer = os.environ.get("AS_PEER") or ""
if as_peer:
if as_peer not in ov["peers"]:
print("no peer named %r in this overlay. known: %s"
% (as_peer, ", ".join(ov["peers"])))
raise SystemExit(1)
me = as_peer
else:
me = None
local = os.popen(
"ip -4 -o addr show 2>/dev/null | awk '{split($4,a,\"/\"); print a[1]}'"
).read().split()
for n, p in ov["peers"].items():
if p.get("address") and p["address"] in local:
me = n
changes = []
conflicts = []
staged = {}
def setf(peer, field, val, why=""):
p = ov["peers"][peer]
if val is None or p.get(field) == val:
return
# Two values for one field means the input is from another machine.
prev = staged.get((peer, field))
if prev is not None and prev != val:
conflicts.append((peer, field, prev, val))
return
staged[(peer, field)] = val
changes.append((peer, field, p.get(field), val, why))
if write:
p[field] = val
if me and iface.get("public_key"):
setf(me, "public_key", iface["public_key"], "(this machine's interface)")
def match(pr):
# 1. The public key IS the identity. Use it whenever the estate knows it.
n = by_key.get(pr["public_key"])
if n:
return n
nets = [a.strip() for a in pr.get("allowed_ips", "").split(",") if a.strip()]
# 2. An allowed-ip that is a declared peer address — the ordinary spoke case.
for a in nets:
if a.split("/")[0] in by_addr:
return by_addr[a.split("/")[0]]
# 3. A peer routing the WHOLE overlay is the hub seen from a spoke. Its
# allowed_ips is the subnet itself, so no single address ever matches it.
if subnet and len(hubs) == 1:
for a in nets:
try:
if ipaddress.ip_network(a, strict=False).supernet_of(subnet):
return hubs[0]
except ValueError:
continue
return None
for pr in peers:
name = match(pr)
if not name:
changes.append(("?", "UNMATCHED", None,
"allowed_ips=%s key=%s" % (pr.get("allowed_ips"), pr["public_key"][:12] + "..."),
"no estate peer has this key, this address, or this route"))
continue
setf(name, "public_key", pr.get("public_key"))
setf(name, "allowed_ips", pr.get("allowed_ips"))
setf(name, "keepalive", pr.get("keepalive"))
# endpoint: recorded ONLY for a non-roaming peer. For a roaming one the
# value is a home ISP address and is deliberately dropped here.
if pr.get("endpoint"):
if ov["peers"][name].get("role") == "roaming":
changes.append((name, "endpoint", None, "(dropped: roaming peer)",
"a home address is the one sensitive field; roaming peers have no stable endpoint"))
else:
setf(name, "endpoint", pr["endpoint"])
if me and iface.get("listen_port"):
try:
lp = int(iface["listen_port"])
except ValueError:
lp = None
if lp is not None:
# A listen port belongs to the PEER, not to the overlay. A roaming peer's
# is an ephemeral source port chosen by the kernel; writing it to the
# overlay would rename the port the firewall rule is checked against.
setf(me, "listen_port", lp)
if ov["peers"][me].get("role") == "hub" and ov.get("listen_port") != lp:
changes.append(("(overlay)", "listen_port", ov.get("listen_port"), lp,
"the hub's port is the overlay's port"))
if write:
ov["listen_port"] = lp
if conflicts:
print("REFUSING: the same field was reported twice with different values.\n")
for peer, field, a, b in conflicts:
print(" %s.%s: %s vs %s" % (peer, field, a, b))
print("\nThis usually means `wg show` output from one machine was piped into")
print("capture on another. Run capture on the machine the output came from.")
raise SystemExit(1)
if not changes:
print("nothing to record — the estate already matches what wg reports.")
else:
print("%-9s %-12s %-22s %s" % ("PEER", "FIELD", "WAS", "WOULD BE"))
for peer, field, was, val, why in changes:
print("%-9s %-12s %-22s %s" % (peer, field, was if was is not None else "—", val))
if why: print(" %s" % why)
if write:
still = [n for n, p in ov["peers"].items() if not p.get("public_key")]
if not still:
d["vpn"]["_status"] = ("CAPTURED %s — public keys, allowed-ips and keepalive read from "
"`wg show`. Roaming endpoints deliberately not recorded."
% __import__("datetime").date.today())
json.dump(d, open(path, "w"), indent=2, ensure_ascii=False)
open(path, "a").write("\n")
print("\nwritten to %s" % path)
else:
print("\nnothing written. Add --write to record it.")
PYCAP
}
refuse() {
local verb="$1"; shift
local yes=""
for a in "$@"; do [ "$a" = "--yes" ] && yes=1; done
[ -z "$yes" ] && {
echo "refusing to $verb without --yes." >&2
echo " $verb changes live networking — it can cut the path this session" >&2
echo " is reaching the estate through. Read 'make vpn check' first." >&2
exit 1; }
echo "refusing to $verb: not implemented. Bringing a tunnel up or down is" >&2
echo " the host's business, and the live one is systemd-managed" >&2
echo " (wg-quick@wg0). berth describes and renders; it does not operate." >&2
exit 1
}
case "${1:-list}" in
list) list ;;
show) shift; show "${1:-}" ;;
check) check ;;
render) shift; render "${1:-}" ;;
keygen) shift; keygen "${1:-}" ;;
capture) shift; capture "$@" ;;
up|down) v="$1"; shift; refuse "$v" "$@" ;;
*) echo "usage: $0 [list|show <ov>|check|render <peer>|keygen <peer>|capture [--as <peer>] [--write]|up --yes|down --yes]" >&2; exit 1 ;;
esac

326
berth/estate/mcrn.json Normal file
View File

@@ -0,0 +1,326 @@
{
"_meta": {
"status": "UNVERIFIED — derived from the repos, not from the estate",
"why": "ppl/infra/ was written and never applied: no ~/.pulumi, no infra/venv, no stack state, files dated 'mar 6'. The estate was built in the console and the IaC is aspirational. B1's inventory is what replaces these values with observed ones; until it runs, every field here is a CLAIM.",
"never_record": "credential values. Resource ids and settings only. nova's gateway secret is deliberately absent from this file even though it is committed in plaintext in ppl/gateway/nginx/conf.d/nova.conf — see services[].raw.",
"sources": [
"ppl/infra/__main__.py",
"ppl/ctrl/dns.sh",
"ppl/ctrl/certs.sh",
"ppl/gateway/docker-compose.yml",
"ppl/gateway/nginx/conf.d/",
"ppl/local/Caddyfile"
],
"placement": {
"box": "a container on the estate's own docker network — upstream is the container name",
"local": "a rig cluster on a peer, reached over the overlay — upstream is that peer's address",
"instance": "a dedicated cloud instance on the overlay — same rendering as `local`",
"hosted": "a managed endpoint. Declared so moving to one is a one-line change; unused.",
"_why": "A service says WHERE it runs. How it is reached follows from that, and the three properties of a static-upstream vhost — upstream{}, no resolver, no set $var — are one decision rather than three."
}
},
"domain": "mcrn.ar",
"local_domain": "local.ar",
"host": "mcrn",
"host_admin": "mcrn-admin",
"instance": {
"type": "t3.small",
"disk_gb": 30,
"disk_type": "gp3",
"image": "debian-12",
"user": "mariano"
},
"firewall": [
{
"port": 22,
"proto": "tcp",
"desc": "SSH"
},
{
"port": 80,
"proto": "tcp",
"desc": "HTTP"
},
{
"port": 443,
"proto": "tcp",
"desc": "HTTPS"
},
{
"port": 3022,
"proto": "tcp",
"desc": "Gitea SSH",
"note": "compose maps 3022:22 but GITEA__server__SSH_PORT=22, so gitea advertises :22 in clone URLs while listening on :3022. B1 confirms which is real."
},
{
"port": 51820,
"proto": "udp",
"desc": "WireGuard",
"note": "ABSENT from ppl/infra/__main__.py's four rules — but the tunnel is live (ping 10.8.0.1 succeeds), so the real security group must already allow it. The code therefore does not describe the estate. Confirm in V1."
}
],
"network": {
"docker_network": "gateway",
"docker_network_note": "A fixed, externally-joinable bridge name. Every unrelated app stack on the box joins it so nginx can resolve them by container name. This is why the gateway compose declares 8 services while nginx routes 20+ hostnames.",
"wireguard_moved": "superseded by the top-level `vpn` block"
},
"vpn": {
"_status": "CAPTURED 2026-09-14 — public keys, allowed-ips and keepalive read from `wg show`. Roaming endpoints deliberately not recorded.",
"_never_record": "private keys. `wg show` prints 'private key: (hidden)' and is the safe capture command. `wg showconf` dumps PrivateKey= in clear — never use it.",
"overlays": {
"estate": {
"purpose": "Connects the estate's machines across clouds without a shared VPC, and carries everything that does not need to be publicly reachable.",
"subnet": "10.8.0.0/24",
"listen_port": 51820,
"peers": {
"box": {
"address": "10.8.0.1",
"role": "hub",
"note": "mcrn.ar. Has a public IP, so it is the peer others dial. Carries the registry (:5000) and woodpecker's gRPC (:9000), both bound to this address and therefore overlay-only.",
"endpoint": "3.23.204.197:51820",
"public_key": "zVYCmi3xucuX7k/aDhrOUPyN4GRk96ffSDD6dUFQjh4=",
"allowed_ips": "10.8.0.0/24",
"keepalive": 25,
"listen_port": 51820
},
"nrft": {
"address": "10.8.0.2",
"role": "roaming",
"note": "The dev box. Behind NAT, so it must initiate and needs PersistentKeepalive. Verified: wg0 UP at 10.8.0.2/24, ping 10.8.0.1 0% loss at 153ms.",
"endpoint": null,
"public_key": "zlIBGs4y5rt6uVdmFBasHpafht6ErxG+R3ySCg5rh3s=",
"allowed_ips": "10.8.0.2/32, 192.168.1.0/24",
"keepalive": null,
"listen_port": 36145
},
"work": {
"address": "10.8.0.3",
"role": "roaming",
"note": "A work computer, granted access when it was needed. Identified by the user at capture time, 2026-09-14 — it was NOT in the description before, and the wire is where it was found. No handshake and no transfer have ever been recorded for it, so it is a standing grant rather than a live peer: it can connect, and never has. Whether to keep or revoke it is the host's call.",
"endpoint": null,
"public_key": "ruSZwKt/p60GVsTLSAhcKBIXKkSZsf0gWSmSH1+UgE0=",
"allowed_ips": "10.8.0.3/32",
"keepalive": null
}
}
}
}
},
"databases": [
"gitea",
"woodpecker",
"umami"
],
"certs": {
"issued": [
"mcrn.ar",
"*.mcrn.ar",
"*.spr.mcrn.ar"
],
"issued_source": "ppl/ctrl/certs.sh:92 — the -d flags passed to certbot",
"note": "What the cert ACTUALLY covers. estate_sans() derives what the services NEED. check.sh compares the two; the difference is the finding, not a restatement."
},
"services": [
{
"name": "gitea",
"host": "git",
"upstream": "gitea:3000",
"targets": [
"aws"
]
},
{
"name": "woodpecker",
"host": "ci",
"upstream": "woodpecker-server:8000",
"targets": [
"aws"
]
},
{
"name": "registry",
"host": "registry",
"upstream": "registry:5000",
"targets": [
"aws"
]
},
{
"name": "umami",
"host": "analytics",
"upstream": "umami:3000",
"targets": [
"aws"
]
},
{
"name": "docserve",
"host": "docs",
"upstream": "docserve:8020",
"targets": [
"aws"
]
},
{
"name": "ghost",
"host": "notes",
"upstream": "ghost:2368",
"targets": [
"aws"
]
},
{
"name": "deskmeter",
"host": "deskmeter",
"upstream": "dmweb:10000",
"targets": [
"aws"
],
"local_port": 10000
},
{
"name": "sysmonstm",
"host": "sysmonstm",
"upstream": "sysmonstm-edge:8080",
"targets": [
"aws"
],
"local_port": 8020
},
{
"name": "malvalava",
"host": "malvalava",
"upstream": "mlvclean-frontend:80",
"targets": [
"aws"
],
"local_port": 30090
},
{
"name": "soleprint",
"host": "soleprint",
"upstream": "soleprint:8000",
"targets": [
"aws"
],
"local_port": 12000
},
{
"name": "dlt",
"host": "dlt.spr",
"upstream": "dlt_spr:8000",
"targets": [
"aws"
]
},
{
"name": "sample",
"host": "sample.spr",
"upstream": "sample_spr:8000",
"targets": [
"aws"
]
},
{
"name": "mariano",
"host": "mariano",
"kind": "static",
"targets": [
"aws"
]
},
{
"name": "rigui",
"host": "rig",
"kind": "static",
"targets": [
"aws"
],
"local_port": 20310
},
{
"name": "unt",
"host": "unt",
"targets": [
"local"
],
"local_port": 8040
},
{
"name": "mpr",
"host": "mpr",
"targets": [
"local"
],
"local_port": 30080
},
{
"name": "nvi",
"host": "nvi",
"targets": [
"local"
],
"local_port": 8060
},
{
"name": "eth",
"host": "eth",
"targets": [
"local"
],
"local_port": 8050
},
{
"name": "amar",
"host": "amar",
"targets": [
"local"
],
"local_port": 8030
},
{
"name": "nova",
"host": "nova",
"upstream": "nova-ui:80",
"targets": [
"aws"
],
"raw": true,
"raw_why": "Gated on an X-Gateway-Secret header whose value is committed in plaintext. The value is NOT recorded here. Worse: stellarair.conf proxies to the SAME nova-ui:80 upstream WITHOUT the check, so the gate is bypassable by hostname. Stays hand-written until that is decided."
},
{
"name": "stellarair",
"host": "stellarair",
"upstream": "nova-ui:80",
"targets": [
"aws"
],
"raw": true,
"raw_why": "See nova. Same upstream, no header gate."
},
{
"name": "langfuse",
"host": "langfuse",
"local_host": "lng",
"placement": "local",
"peer": "nrft",
"port": 3000,
"targets": [
"aws",
"local"
],
"local_port": 3000,
"note": "One service, one socket, two names. It was two entries with one flagged `raw`; placement is what made the exception expressible, so it is generated now."
},
{
"name": "legacy",
"host": "*.soleprint",
"upstream": "soleprint:8000",
"targets": [
"aws"
],
"raw": true,
"raw_why": "A regex server_name with a named capture plus sub_filter injection — not expressible as a template. ALSO BROKEN: its /api/, /admin/, /static/ and / blocks proxy to 127.0.0.1, i.e. inside the nginx container where nothing listens, so every legacy room 502s. Only /wrapper/ uses the correct container-name form."
}
]
}

213
build.py
View File

@@ -23,6 +23,7 @@ Generated structure for managed rooms:
""" """
import argparse import argparse
import importlib.util
import json import json
import logging import logging
import shutil import shutil
@@ -79,6 +80,30 @@ def _rmtree_resilient(path: Path):
) )
# Never swept into a built room, wherever they appear in a source tree.
#
# This is a SECURITY boundary, not tidiness. gen/<room>/ is the docker build
# context, soleprint/Dockerfile is `COPY . .`, and there is no .dockerignore —
# so anything that reaches gen/ reaches an image layer, and registry.mcrn.ar is
# public-read. That is how station/tools/tester/.env, gitignored since the last
# incident, still ended up baked into soleprint_localtest-soleprint:latest with
# its API key intact. .gitignore does not bind shutil.
#
# Applied to bulk directory copies only. A caller naming a single file is making
# an explicit request (cfg/<room>/.env.example is the one that matters) and is
# left alone.
ALWAYS_IGNORE = {".git", "__pycache__", "node_modules", ".venv", "venv", ".env"}
ALWAYS_IGNORE_SUFFIXES = (".pyc", ".pyo")
def is_ignored(name: str) -> bool:
return name in ALWAYS_IGNORE or name.endswith(ALWAYS_IGNORE_SUFFIXES)
def _copytree_ignore(directory, files):
return {f for f in files if is_ignored(f)}
def copy_path(source: Path, target: Path, quiet: bool = False): def copy_path(source: Path, target: Path, quiet: bool = False):
"""Copy file or directory, resolving symlinks.""" """Copy file or directory, resolving symlinks."""
if target.is_symlink(): if target.is_symlink():
@@ -90,7 +115,7 @@ def copy_path(source: Path, target: Path, quiet: bool = False):
target.unlink() target.unlink()
if source.is_dir(): if source.is_dir():
shutil.copytree(source, target, symlinks=False) shutil.copytree(source, target, symlinks=False, ignore=_copytree_ignore)
if not quiet: if not quiet:
log.info(f" {target.name}/") log.info(f" {target.name}/")
else: else:
@@ -110,6 +135,8 @@ def merge_into(source: Path, target: Path):
for item in source.rglob("*"): for item in source.rglob("*"):
if item.is_file(): if item.is_file():
rel = item.relative_to(source) rel = item.relative_to(source)
if any(is_ignored(part) for part in rel.parts):
continue
dest = target / rel dest = target / rel
dest.parent.mkdir(parents=True, exist_ok=True) dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(item, dest) shutil.copy2(item, dest)
@@ -532,6 +559,148 @@ def _append_cabinet_env(output_dir: Path, cabinets: list[dict]):
example.write_text(existing.rstrip("\n") + "\n" + "\n".join(lines) + "\n") example.write_text(existing.rstrip("\n") + "\n" + "\n".join(lines) + "\n")
def load_plexuses(room: str) -> list[dict]:
"""The plexuses a room asked for. Same shape as its sibling data/*.json."""
path = SPR_ROOT / "cfg" / room / "data" / "plexuses.json"
if not path.exists():
return []
try:
raw = json.loads(path.read_text())
except ValueError as e:
log.warning(f" plexuses.json is not valid JSON, ignoring: {e}")
return []
entries = raw.get("items", raw) if isinstance(raw, dict) else raw
out = []
for entry in entries if isinstance(entries, list) else []:
if isinstance(entry, str):
entry = {"name": entry}
if isinstance(entry, dict) and entry.get("name"):
out.append(entry)
return out
def _theme_css(theme: str) -> str:
"""The token contract plus one theme, flattened for inlining.
Only the named theme ships alongside the others it can switch to, because
the export has to work with no server: there is no /theme.css to fetch.
"""
theme_dir = SPR_ROOT / "soleprint" / "common" / "theme"
parts = []
tokens = theme_dir / "tokens.css"
if tokens.exists():
parts.append(tokens.read_text())
# Every theme, so the switcher in the page has something to switch to.
for sheet in sorted((theme_dir / "themes").glob("*.css")):
parts.append(sheet.read_text())
return "\n".join(parts)
def _inline_svg(name: str, theme: str) -> str:
"""A rendered graph, stripped of its XML prolog so it can sit in HTML.
Inlined rather than <img>-linked so the page's CSS can recolour it when the
theme switches — graphviz writes class="node accent" into the SVG, and CSS
outranks the presentation attributes it bakes in.
"""
graphs = SPR_ROOT / "docs" / "graphs"
for candidate in (graphs / f"{name}.{theme}.svg", graphs / f"{name}.svg"):
if candidate.exists():
svg = candidate.read_text()
start = svg.find("<svg")
return svg[start:] if start >= 0 else svg
log.warning(f" no rendered graph '{name}' — run docs/graphs/render.sh")
return "<p>diagram not rendered</p>"
def build_plexuses(output_dir: Path, room: str):
"""Export each plexus the room declared to a single self-contained file.
A plexus is exported, not served. The output is one index.html carrying its
theme, its data and its diagram, so it survives a locked-down machine, a zip
attachment and a double-click — which is the whole point of the format.
"""
requested = load_plexuses(room)
if not requested:
return
source_root = SPR_ROOT / "soleprint" / "artery" / "plexuses"
built = []
for entry in requested:
name = entry["name"]
source = source_root / name
manifest_path = source / "plexus.json"
if not manifest_path.exists():
available = sorted(
p.name for p in source_root.iterdir() if p.is_dir()
) if source_root.exists() else []
log.warning(
f" no such plexus: {name} (available: {', '.join(available) or 'none'})"
)
continue
try:
manifest = json.loads(manifest_path.read_text())
except ValueError as e:
log.warning(f" plexus {name} has invalid plexus.json: {e}")
continue
# The room may override anything the plexus declares — theme first.
manifest.update({k: v for k, v in entry.items() if k != "name"})
template_path = source / "app" / "index.html"
if not template_path.exists():
log.warning(f" plexus {name} has no app/index.html")
continue
theme = manifest.get("theme", "soleprint")
data = {k: v for k, v in manifest.items() if not k.startswith("_")}
# A plexus may ship a showcase.py exposing collect(): anything it returns
# is merged into the page's data. The bundle uses it to run the real
# tools over the real fixtures at build time, so what the page shows
# cannot drift from what the tools do.
collector = source / "showcase.py"
if collector.exists():
try:
spec = importlib.util.spec_from_file_location(
f"plexus_{name}_showcase", collector
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
data["showcase"] = module.collect()
except Exception as e:
log.warning(f" {name}: showcase.py failed ({type(e).__name__}: {e})")
page = template_path.read_text()
for token, value in (
("%%TITLE%%", manifest.get("title", name)),
("%%DESCRIPTION%%", manifest.get("description", "")),
("%%DEFAULT_THEME%%", theme),
("%%BUILT%%", f"{room} · built by soleprint build.py"),
("%%THEME_CSS%%", _theme_css(theme)),
("%%GRAPH%%", _inline_svg(manifest.get("graph", "system_overview"), theme)),
("%%BUNDLE%%", json.dumps(data, indent=2)),
):
page = page.replace(token, value)
target = output_dir / "plexuses" / name
ensure_dir(target)
(target / "index.html").write_text(page)
# Anything else in app/ rides along, for a plexus that outgrows one file.
for extra in (source / "app").iterdir():
if extra.name != "index.html":
copy_path(extra, target / extra.name, quiet=True)
built.append(f"{name} ({theme})")
if built:
log.info(f" plexuses: {', '.join(built)}")
def build_soleprint(output_dir: Path, room: str): def build_soleprint(output_dir: Path, room: str):
"""Build soleprint folder with core + room config merged.""" """Build soleprint folder with core + room config merged."""
soleprint = SPR_ROOT / "soleprint" soleprint = SPR_ROOT / "soleprint"
@@ -544,6 +713,7 @@ def build_soleprint(output_dir: Path, room: str):
"index.html", "index.html",
"requirements.txt", "requirements.txt",
"Dockerfile", "Dockerfile",
".dockerignore",
]: ]:
if (soleprint / name).exists(): if (soleprint / name).exists():
copy_path(soleprint / name, output_dir / name) copy_path(soleprint / name, output_dir / name)
@@ -568,6 +738,11 @@ def build_soleprint(output_dir: Path, room: str):
log.info("Composing cabinets...") log.info("Composing cabinets...")
compose_cabinets(output_dir, room) compose_cabinets(output_dir, room)
# Plexuses are exported rather than served, so this is a compile step like
# the cabinet merge above — not something run.py does at request time.
log.info("Exporting plexuses...")
build_plexuses(output_dir, room)
# Generate models # Generate models
log.info("Generating models...") log.info("Generating models...")
if not generate_models(output_dir, room): if not generate_models(output_dir, room):
@@ -624,6 +799,33 @@ def build_models_only():
sys.exit(1) sys.exit(1)
def build_plexuses_only(room: str):
"""Compile just the plexus UIs, without rebuilding the room around them.
The equivalent of `vite build` for this repo: the iteration loop when you
are working on the UI itself is edit, compile, reopen the file — and a full
room build to see a CSS change is a slow way to do that.
"""
output_dir = SPR_ROOT / "gen" / room
if not output_dir.exists():
log.error(f"Room '{room}' is not built — run: python build.py --cfg {room}")
sys.exit(1)
log.info(f"Compiling plexus UIs for {room}...")
build_plexuses(output_dir, room)
built = sorted((output_dir / "plexuses").glob("*/index.html"))
if not built:
log.warning(
f" nothing compiled — does cfg/{room}/data/plexuses.json list one?"
)
return
for page in built:
log.info(f" {page.relative_to(SPR_ROOT)} ({page.stat().st_size // 1024} KB)")
log.info("\n✓ Open directly — no server needed:")
log.info(f" xdg-open {built[0]}")
def main(): def main():
parser = argparse.ArgumentParser(description="Soleprint Build Tool") parser = argparse.ArgumentParser(description="Soleprint Build Tool")
@@ -631,10 +833,17 @@ def main():
parser.add_argument("--cfg", "-c", type=str, help="Room config name") parser.add_argument("--cfg", "-c", type=str, help="Room config name")
parser.add_argument("--all", action="store_true", help="Build all rooms") parser.add_argument("--all", action="store_true", help="Build all rooms")
parser.add_argument("--models", action="store_true", help="Only regenerate models") parser.add_argument("--models", action="store_true", help="Only regenerate models")
parser.add_argument(
"--plexuses",
action="store_true",
help="Only compile the plexus UIs into an already-built room",
)
args = parser.parse_args() args = parser.parse_args()
if args.models: if args.plexuses:
build_plexuses_only(args.cfg or "standalone")
elif args.models:
build_models_only() build_models_only()
elif args.all: elif args.all:
build(SPR_ROOT / "gen" / "standalone", None) build(SPR_ROOT / "gen" / "standalone", None)

View File

@@ -1,3 +1,7 @@
{ {
"items": [] "items": [
{
"name": "bundle"
}
]
} }

View File

@@ -6,16 +6,51 @@
# ./ctrl/cluster.sh down # delete it (drops every room's namespace) # ./ctrl/cluster.sh down # delete it (drops every room's namespace)
# ./ctrl/cluster.sh status # what's running on it # ./ctrl/cluster.sh status # what's running on it
# #
# One target, one script — the variants live here. The kind-*.sh files stay # spr depends on rig, never the other way round. Building and deleting a cluster
# exactly as they are and remain runnable on their own; this only dispatches. # is rig's job, so up and down hand straight to rig/ctrl/cluster.sh, carrying the
# the things that make this cluster spr's rather than rig's defaults:
#
# CLUSTER=spr rooms deploy into the kind-spr context
# KIND_CONFIG spr's own kind config, which maps the rooms' gateway NodePorts
# REGISTRY_MODE=none rooms load images straight into the node
# PROFILE= ADDONS= set empty here, so rig's own ctrl/.env can never quietly
# OVERLAY= pick a profile, an overlay or addons for spr's cluster
#
# status stays here: it answers a question about rooms, not about the cluster.
set -e set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RIG_CTRL="$SCRIPT_DIR/../rig/ctrl"
rig() {
CLUSTER=spr \
KIND_CONFIG="$SCRIPT_DIR/k8s/kind-config.yaml" \
REGISTRY_MODE=none \
PROFILE= \
OVERLAY= \
ADDONS= \
bash "$RIG_CTRL/cluster.sh" "$@"
}
case "${1:-status}" in case "${1:-status}" in
up) exec "$SCRIPT_DIR/kind-up.sh" ;; up)
down) exec "$SCRIPT_DIR/kind-down.sh" ;; rig up
status) exec "$SCRIPT_DIR/kind-status.sh" ;; echo
echo "Per-room deploy:"
echo " cd gen/<room> && ./ctrl/k8s-up.sh"
;;
down)
rig down
;;
status)
if ! kind get clusters 2>/dev/null | grep -qx spr; then
echo "No 'spr' kind cluster — run: make cluster up"
exit 0
fi
kubectl --context kind-spr get namespaces -l soleprint-room
echo
kubectl --context kind-spr get pods -A -l soleprint-room
;;
*) *)
echo "Unknown subcommand: $1" >&2 echo "Unknown subcommand: $1" >&2
echo "Usage: cluster.sh [up|down|status]" >&2 echo "Usage: cluster.sh [up|down|status]" >&2

View File

@@ -41,6 +41,14 @@ if [ "$SYNC_ONLY" = true ]; then
fi fi
echo "Restarting soleprint on server..." echo "Restarting soleprint on server..."
ssh "$SERVER" "cd $REMOTE_DIR && docker compose up -d --build" # The compose file runs the container as ${UID:-1000}:${GID:-1000} and bind-mounts
# the deployed tree at /app. Those have to be the ids that OWN the tree, and they
# are not 1000 on every host — mcrn.ar's user is 1001. Without this the container
# starts fine and then 500s on the first file it reads, which reads as an app bug
# rather than a permissions one.
#
# `env` rather than a prefix assignment: UID is readonly in bash, so
# `UID=$(id -u) docker ...` fails outright.
ssh "$SERVER" "cd $REMOTE_DIR && env UID=\$(id -u) GID=\$(id -g) docker compose up -d --build"
echo "Deploy complete" echo "Deploy complete"

31
ctrl/dist.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Compile the plexus UIs to distributable files — this repo's `vite build`.
#
# Usage:
# ./ctrl/dist.sh # standalone
# ./ctrl/dist.sh sample # a named room
#
# A plexus is a UI that gets EXPORTED, not served. The output is a single
# index.html carrying its theme, its data and its diagrams inline, so it opens
# from a double-click on a machine with no server, no node and no network — the
# state a regulated Windows box is usually in.
#
# `make build` runs this as one of its steps. This exists for the loop where the
# UI is what you are working on: rebuilding a whole room to see a CSS change is
# a slow way to iterate.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$ROOT_DIR"
PYTHON="${PYTHON:-python3}"
ROOM="${1:-standalone}"
if [[ ! -d "cfg/$ROOM" ]]; then
echo "No such room: cfg/$ROOM" >&2
echo "Available: $(find cfg -mindepth 1 -maxdepth 1 -type d -not -name '.*' -printf '%f ')" >&2
exit 1
fi
exec "$PYTHON" build.py --plexuses --cfg "$ROOM"

47
ctrl/docs.sh Executable file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Documentation: serve the pages, and re-render the diagrams.
#
# Usage: docs.sh serve [port] | graphs [theme]
#
# The docs are a static SPA — index.html plus data/*.md read at runtime — so
# they need a server only because fetch() refuses file:// origins. Any static
# server does; python is already a hard dependency here (build.py is python), so
# there is no reason to reach for docker the way rig does.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
DOCS_DIR="$ROOT_DIR/docs"
PYTHON="${PYTHON:-python3}"
PORT="${DOCS_PORT:-8080}"
serve() {
[ -n "${1:-}" ] && PORT="$1"
if [ ! -f "$DOCS_DIR/index.html" ]; then
echo "no docs/index.html" >&2
exit 1
fi
echo "docs on http://localhost:${PORT}/"
echo " (ctrl-c to stop; nothing is installed and nothing persists)"
cd "$DOCS_DIR"
exec "$PYTHON" -m http.server "$PORT"
}
# Re-render docs/graphs/*.dot through every theme. The sources carry structure;
# the palette lives in docs/graphs/themes/*.gvpr. See docs/graphs/README.md.
graphs() {
if [ ! -x "$DOCS_DIR/graphs/render.sh" ]; then
echo "no docs/graphs/render.sh" >&2
exit 1
fi
exec bash "$DOCS_DIR/graphs/render.sh" "$@"
}
case "${1:-serve}" in
serve) shift || true; serve "$@" ;;
graphs) shift || true; graphs "$@" ;;
# `make docs 8090` is the obvious thing to type, so take it.
''|*[!0-9]*) echo "usage: $0 [serve [port]|graphs [theme]]" >&2; exit 1 ;;
*) serve "$1" ;;
esac

View File

@@ -3,9 +3,15 @@ apiVersion: kind.x-k8s.io/v1alpha4
# Single shared cluster for all soleprint rooms. # Single shared cluster for all soleprint rooms.
# Each room deploys into its own namespace; gateway Services pick a # Each room deploys into its own namespace; gateway Services pick a
# NodePort from the 30080-30099 range mapped here. # NodePort from the 30080-30099 range mapped here.
name: spr #
# Built by rig, not by spr: ctrl/cluster.sh hands this file to
# rig/ctrl/cluster.sh, which substitutes CLUSTER and NODE_IMAGE (named without
# braces here so this comment survives the substitution). The shape is spr's —
# what its cluster needs is spr's business. Building it is rig's.
name: ${CLUSTER}
nodes: nodes:
- role: control-plane - role: control-plane
image: ${NODE_IMAGE}
extraPortMappings: extraPortMappings:
# Room gateway NodePorts (one per active room). # Room gateway NodePorts (one per active room).
- {containerPort: 30080, hostPort: 30080, protocol: TCP} - {containerPort: 30080, hostPort: 30080, protocol: TCP}

View File

@@ -1,12 +0,0 @@
#!/bin/bash
# Delete the shared `spr` kind cluster (drops every room's namespace too).
# Use `gen/<room>/ctrl/k8s-down.sh` instead if you only want to remove
# a single room's namespace.
set -e
if kind get clusters 2>/dev/null | grep -q '^spr$'; then
echo "Deleting kind cluster 'spr'..."
kind delete cluster --name spr
else
echo "No kind cluster 'spr' to delete."
fi

View File

@@ -1,12 +0,0 @@
#!/bin/bash
# Show what's running on the shared `spr` cluster.
set -e
if ! kind get clusters 2>/dev/null | grep -q '^spr$'; then
echo "No 'spr' kind cluster — run ctrl/kind-up.sh"
exit 0
fi
kubectl --context kind-spr get namespaces -l soleprint-room
echo
kubectl --context kind-spr get pods -A -l soleprint-room

View File

@@ -1,21 +0,0 @@
#!/bin/bash
# Create (or no-op) the single shared `spr` kind cluster used by every
# soleprint room. Per-room work happens inside namespaces — see
# `gen/<room>/ctrl/k8s-up.sh`.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
KIND_CONFIG="$SCRIPT_DIR/k8s/kind-config.yaml"
if kind get clusters 2>/dev/null | grep -q '^spr$'; then
echo "Kind cluster 'spr' already exists."
else
echo "Creating kind cluster 'spr'..."
kind create cluster --config "$KIND_CONFIG"
fi
kubectl config use-context kind-spr >/dev/null
echo
echo "Cluster ready. Per-room deploy:"
echo " cd gen/<room> && ./ctrl/k8s-up.sh"

80
ctrl/theme.sh Executable file
View File

@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Bake the theme and its parts into the pages that use them.
#
# Usage:
# ./ctrl/theme.sh # bake — rewrite every generated block
# ./ctrl/theme.sh check # fail if any page is stale; changes nothing
# ./ctrl/theme.sh new [title] # a scaffold page to start from
# ./ctrl/theme.sh run FILE [--list|--check] [--only NAME]
# # every page a run file lists, each with a contract
# ./ctrl/theme.sh parts # what can be added, and the markup that adds it
# ./ctrl/theme.sh export [name...] # the contract for a subset, as one doc
#
# `export` is for handing a vetted LLM what it needs to write an ad-hoc page —
# the chosen parts, their markup, and the tokens resolved to literal values, so
# the document stands alone. Naming parts is the point: hand over everything and
# you get back a page built from Vue components that cannot run standalone.
#
# ./ctrl/theme.sh export panel split > /tmp/contract.md
#
# Call the script directly when piping; `make` echoes its recipe to stdout.
# For whole-repo context this is the wrong tool — station/tools/distill already
# flattens a tree to one budgeted document.
#
# A page that says `background: var(--bg)` and never gets `--bg` is UNSTYLED,
# not merely unbranded — the declaration is invalid at computed-value time. That
# is why every page carries a baked default, and why `check` is worth running.
#
# This exists because bake.py was reachable by no command at all: not from the
# Makefile, not from ctrl/, not from build.py. A drift check nobody runs is a
# drift check that reports nothing, and the evidence was already on disk —
# histgen's page linked /theme.css for months, was missing from the old
# hardcoded page list, and so was never baked once.
set -e
# Where the caller stood. A run file is named relative to there, and the cd below
# would otherwise make `run ./theme.toml` mean a different file.
CALLER_DIR="$PWD"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$ROOT_DIR/soleprint"
PYTHON="${PYTHON:-python3}"
case "${1:-bake}" in
bake) exec "$PYTHON" common/theme/bake.py ;;
check) exec "$PYTHON" common/theme/bake.py --check ;;
parts)
exec "$PYTHON" common/theme/bake.py --parts
;;
run)
# A run file: every page a project has, its context, and a contract per
# page for the LLM. See soleprint/common/theme/theme.example.toml.
shift
args=() file="" prev=""
for a in "$@"; do
if [[ -z "$file" && "$a" != -* && "$prev" != "--only" ]]; then
file="$(cd "$CALLER_DIR" && realpath -m -- "$a")"
args+=("$file")
else
args+=("$a")
fi
prev="$a"
done
exec "$PYTHON" common/theme/bake.py --run "${args[@]}"
;;
new)
shift
exec "$PYTHON" common/theme/bake.py --new "$@"
;;
export)
shift
exec "$PYTHON" common/theme/bake.py --export "$@"
;;
*)
echo "Unknown: $1" >&2
echo "Usage: ./ctrl/theme.sh [new [title]|parts|bake|check|export [name...]|run FILE]" >&2
exit 1
;;
esac

View File

@@ -0,0 +1,93 @@
# Plexuses
A **plexus** is a full app — the vocabulary has always said so
(*"full app with backend, frontend and DB"*). What was missing is that a plexus
is **exported, not served**. It is compiled to a distributable file the way vite
builds for production, and that compile is the whole point of the format.
```bash
make dist # compile the plexus UIs for standalone
make dist sample # for a named room
```
Output is one `index.html` per plexus under `gen/<room>/plexuses/<name>/`,
carrying its theme, its data and its diagrams inline. No server, no node, no
network. Zip it, mail it, double-click it.
`make build` runs the same step as part of a room build. `make dist` exists for
the loop where the UI is what you are working on — rebuilding a whole room to
see a CSS change is a slow way to iterate.
## The constraint that shapes it
It has to open from a **double-clicked file on a machine with no egress**. That
is the state a regulated Windows box is usually in, and it rules out three
things a normal web app does:
| Ruled out | Because |
| --- | --- |
| `fetch("bundle.json")` | `file://` treats every sibling file as cross-origin |
| `<link href="/theme.css">` | an absolute path assumes a server at the root |
| a webfont `@import` | a blocked stylesheet is a stall, not a fallback |
So the data is a JS object, the theme is inlined at compile time, and the fonts
are stacks. The test that matters is opening the output with the network off and
seeing zero failed requests — everything else is cosmetic.
## Declaring one
A room opts in through `cfg/<room>/data/plexuses.json`, the same shape and the
same place as its sibling `data/*.json` files:
```json
{ "items": [ { "name": "bundle" } ] }
```
A room may override anything the plexus declares — most usefully the theme:
```json
{ "items": [ { "name": "bundle", "theme": "mcrn" } ] }
```
## Writing one
```
soleprint/artery/plexuses/<name>/
plexus.json identity, theme, the data the page renders
app/index.html the template
```
`build.py` fills these placeholders and writes one file:
| Placeholder | Becomes |
| --- | --- |
| `%%THEME_CSS%%` | tokens plus every theme, so the switcher has something to switch to |
| `%%BUNDLE%%` | `plexus.json` as a JS object |
| `%%GRAPH%%` | a rendered SVG from `docs/graphs/`, inlined |
| `%%TITLE%%` `%%DESCRIPTION%%` `%%DEFAULT_THEME%%` `%%BUILT%%` | from the manifest |
Anything else in `app/` is copied alongside, for a plexus that outgrows one file.
## The bundle plexus
The one that ships. It answers "what does a rig installation have at its
disposal" — tools, cabinets, veins, themes — and embeds the system diagram.
Because the SVG is **inlined** rather than `<img>`-linked, the theme switch
recolours the diagram too: graphviz writes `class="node accent"` into the SVG,
and CSS outranks the presentation attributes it bakes in. Switching to `lucid`
turns both the page and the diagram into something printable, which is the
demonstration the format exists for.
## Not the same as rig's bundle
Two artifacts, both called bundle, generated by different repos:
| | soleprint | rig |
| --- | --- | --- |
| Command | `make dist` | `make manifest` in `sample-rig` |
| Artifact | `plexuses/<name>/index.html` | `generated/<slug>.yaml` |
| Needs | nothing | kind + MetalLB |
| Answers | what shipped, on any machine | whether this cluster install is sound |
Complementary. One proves the environment, the other travels.

View File

@@ -19,6 +19,8 @@ make start # run it
| Command | Runs | Does | | Command | Runs | Does |
| --- | --- | --- | | --- | --- | --- |
| `make build [<room>\|all\|models]` | `ctrl/build.sh` | compile a room into `gen/` | | `make build [<room>\|all\|models]` | `ctrl/build.sh` | compile a room into `gen/` |
| `make dist [<room>]` | `ctrl/dist.sh` | compile just the plexus UIs to single files |
| `make docs [serve\|graphs]` | `ctrl/docs.sh` | serve the docs, or re-render the diagrams |
| `make start [<room>] [-d]` | `ctrl/start.sh` | run a built room's compose stack | | `make start [<room>] [-d]` | `ctrl/start.sh` | run a built room's compose stack |
| `make stop [<room>]` | `ctrl/stop.sh` | stop it | | `make stop [<room>]` | `ctrl/stop.sh` | stop it |
| `make cluster [up\|down\|status]` | `ctrl/cluster.sh` | the shared kind cluster | | `make cluster [up\|down\|status]` | `ctrl/cluster.sh` | the shared kind cluster |
@@ -50,9 +52,12 @@ make component ARGS="publish soleprint-ui /tmp/out --dist"
4. **Compose cabinets.** The dependency containers the room declared in 4. **Compose cabinets.** The dependency containers the room declared in
`data/cabinets.json` are merged into its `docker-compose.yml`. See `data/cabinets.json` are merged into its `docker-compose.yml`. See
[Cabinets](#station-cabinets). [Cabinets](#station-cabinets).
5. **Generate models.** modelgen reads the room's `config.json` and writes 5. **Export plexuses.** Each plexus the room declared in `data/plexuses.json` is
compiled to a single self-contained `index.html` — theme, data and diagrams
inlined, so it opens with no server. See [Plexuses](#artery-plexuses).
6. **Generate models.** modelgen reads the room's `config.json` and writes
`models/pydantic/__init__.py`. `models/pydantic/__init__.py`.
6. **Render k8s** (optional). When the room's config enables it, 7. **Render k8s** (optional). When the room's config enables it,
`soleprint/ctrl/k8s/` writes manifests and lifecycle scripts. `soleprint/ctrl/k8s/` writes manifests and lifecycle scripts.
## What comes out ## What comes out
@@ -66,6 +71,7 @@ gen/standalone/
cfg/config.json cfg/config.json
data/*.json data/*.json
models/pydantic/ models/pydantic/
plexuses/<name>/index.html # one file each, opens with no server
``` ```
A **managed** room — one that wraps an existing application — is three folders A **managed** room — one that wraps an existing application — is three folders
@@ -122,3 +128,17 @@ the far side. `.env` is excluded, so server secrets stay on the server.
default) straight from the source tree. It is for developing the framework default) straight from the source tree. It is for developing the framework
itself; a room's `cfg/config.json` does not exist there, so the landing pages itself; a room's `cfg/config.json` does not exist there, so the landing pages
fall back to their defaults. Rooms use docker. fall back to their defaults. Rooms use docker.
## Diagrams
The `.dot` sources under `docs/graphs/` carry structure; the palette lives in
`docs/graphs/themes/*.gvpr` and is applied at render time, so one source renders
in every theme.
```bash
make docs graphs # every graph, every theme
make docs graphs lucid # one theme
```
`<name>.svg` is the dark default the docs link to; other themes write
`<name>.<theme>.svg`. See [Themes](#themes).

View File

@@ -57,8 +57,7 @@ compose, the same dependency installs as a rig addon of that name:
```bash ```bash
cd rig cd rig
PROFILE=data make cluster up PROFILE=data make cluster up # installs the addons too
PROFILE=data make addons install
kubectl -n data port-forward svc/postgres 5432:5432 kubectl -n data port-forward svc/postgres 5432:5432
kubectl -n data port-forward svc/airflow 8080:8080 kubectl -n data port-forward svc/airflow 8080:8080

110
docs/data/en/themes.md Normal file
View File

@@ -0,0 +1,110 @@
# Themes
Three themes ship, and the same palettes drive the diagrams as well as the
pages. `soleprint` is the default; the others are switched to on purpose.
| Theme | Reads as | For |
| --- | --- | --- |
| `soleprint` | dark, rounded, amber | the default — tools and dev chrome |
| `mcrn` | dark, square, monospace, orange glow | the terminal look, matched to mariano.mcrn.ar |
| `lucid` | light, hairline, printable | regulated documents, and sitting beside a real lucid.app export |
Switch with `?theme=lucid`, or the toggle in the corner. The choice persists.
## Where it lives
```
soleprint/common/theme/
tokens.css the vocabulary + neutral defaults
themes/*.css one file per theme
theme.js resolve, apply, remember
bake.py inline the defaults into pages
```
Served together at `/theme.css` — tokens first, then every theme, each scoped to
`[data-theme="..."]`. Adding a theme is adding a file: `run.py` lists the
directory rather than carrying a hardcoded list.
## Two naming families, one set of values
Both are answered, because both were already in use and renaming across a dozen
templates would have been the larger change:
- `--bg` / `--surface` / `--border` / `--text` / `--muted` / `--accent` — the
station tools and the docs site
- `--surface-0..3` / `--text-primary` / `--panel-radius``common/ui`'s Vue
components
The second family is derived from the first in `tokens.css`, so a page using
either name gets the same colour and a theme author fills in one set.
## Order matters
```html
<!-- baked defaults --> <style>:root { }</style>
<link rel="stylesheet" href="/theme.css">
<style> the page's own rules </style>
```
The theme has to load **before** the page's styles, so its element defaults
underpin the page rather than override it. Get this backwards and
`tokens.css`'s `body { background: var(--bg) }` flattens whatever the page
wanted — which is exactly how artery, atlas and station briefly lost their
coloured content columns.
## Baked defaults, and why
`/theme.css` is an absolute path, and soleprint is not always at the root — in a
room's nginx it sits under `/spr/` while `location /` goes to the frontend. A
page that says `background: var(--bg)` and never receives `--bg` does not fall
back to something plainer: the declaration is invalid at computed-value time, so
the background goes transparent and the text goes initial-black on a design that
assumed dark. Unstyled, not merely unbranded.
So every page carries a generated `:root` block **before** the link. Both are
`:root`, so document order decides: the served stylesheet wins when it loads,
and the baked block is what is left when it does not.
```bash
python3 common/theme/bake.py # regenerate
python3 common/theme/bake.py --check # fail if a page is stale
```
Only the variables a page actually uses are emitted, so the blocks stay small.
## No webfonts
The stacks name faces that exist on the target rather than fetching any:
```css
--font-ui: "Segoe UI", Inter, system-ui, -apple-system, Arial, sans-serif;
--font-mono: "Cascadia Mono", "JetBrains Mono", Consolas, "SF Mono", monospace;
```
Segoe UI and Consolas ship with Windows. A regulated network blocks
`fonts.googleapis.com` and `file://` stalls on it, and neither failure looks
like a missing font — they look like a broken page.
## Diagrams follow
`docs/graphs/themes/*.gvpr` carry the same palettes for graphviz, so a diagram
and the page around it are one visual language. See
[the graphs README](https://git.mcrn.ar/mariano/soleprint/src/branch/main/docs/graphs/README.md)
and [Export / Compile](#export).
```bash
make docs graphs # every graph, every theme
```
Diagrams are baked per theme rather than styled by CSS, because the docs embed
them with `<img src=…>` — which makes the SVG a separate document the page's
stylesheet cannot reach. A plexus that **inlines** the SVG can style it live,
and the bundle plexus does exactly that.
## Contrast
`lucid` is the first light theme, and the palette was measured rather than
guessed — against both `#ffffff` and the `#f5f7fa` panel, at the sizes actually
used. `--dim` drives 11px notes and `--status-warn` drives 10px labels, so both
need 4.5:1 rather than the 3:1 large text gets away with. The obvious lighter
greys came in at 3.43.8 and were dropped for that reason.

View File

@@ -1,32 +1,188 @@
[ [
{"id": "intro", "title": {"en": "Introduction"}}, {
{"id": "quickstart", "title": {"en": "Quick Start"}}, "id": "intro",
{"id": "concepts", "title": {"en": "Concepts"}}, "title": {
{"id": "room-setup", "title": {"en": "↳ Room Setup"}, "sub": true}, "en": "Introduction"
{"id": "standalone", "title": {"en": "↳ Standalone"}, "sub": true}, }
{"id": "managed", "title": {"en": "↳ Managed"}, "sub": true}, },
{
{"id": "artery", "title": {"en": "Artery"}}, "id": "quickstart",
{"id": "artery-jira", "title": {"en": "↳ Jira"}, "sub": true}, "title": {
{"id": "artery-google", "title": {"en": "↳ Google"}, "sub": true}, "en": "Quick Start"
{"id": "artery-slack", "title": {"en": "↳ Slack"}, "sub": true}, }
{"id": "artery-ia", "title": {"en": "↳ IA"}, "sub": true}, },
{"id": "artery-shunts", "title": {"en": "↳ Shunts"}, "sub": true}, {
"id": "concepts",
{"id": "atlas", "title": {"en": "Atlas"}}, "title": {
{"id": "atlas-books", "title": {"en": "↳ Books"}, "sub": true}, "en": "Concepts"
{"id": "atlas-templates", "title": {"en": "↳ Templates"}, "sub": true}, }
},
{"id": "station", "title": {"en": "Station"}}, {
{"id": "station-tester", "title": {"en": "↳ Tester"}, "sub": true}, "id": "room-setup",
{"id": "station-datagen", "title": {"en": "↳ Datagen"}, "sub": true}, "title": {
{"id": "station-modelgen", "title": {"en": "↳ Modelgen"}, "sub": true}, "en": "↳ Room Setup"
{"id": "station-graphgen", "title": {"en": "↳ Graphgen"}, "sub": true}, },
{"id": "station-shuntgen", "title": {"en": "↳ Shuntgen"}, "sub": true}, "sub": true
{"id": "station-databrowse", "title": {"en": "↳ Databrowse"}, "sub": true}, },
{"id": "station-cabinets", "title": {"en": "↳ Cabinets"}, "sub": true}, {
"id": "standalone",
{"id": "components", "title": {"en": "Shared Components"}}, "title": {
{"id": "export", "title": {"en": "Export / Compile"}}, "en": "↳ Standalone"
{"id": "deployment", "title": {"en": "Deployment"}} },
"sub": true
},
{
"id": "managed",
"title": {
"en": "↳ Managed"
},
"sub": true
},
{
"id": "artery",
"title": {
"en": "Artery"
}
},
{
"id": "artery-jira",
"title": {
"en": "↳ Jira"
},
"sub": true
},
{
"id": "artery-google",
"title": {
"en": "↳ Google"
},
"sub": true
},
{
"id": "artery-slack",
"title": {
"en": "↳ Slack"
},
"sub": true
},
{
"id": "artery-ia",
"title": {
"en": "↳ IA"
},
"sub": true
},
{
"id": "artery-shunts",
"title": {
"en": "↳ Shunts"
},
"sub": true
},
{
"id": "artery-plexuses",
"title": {
"en": "↳ Plexuses"
},
"sub": true
},
{
"id": "atlas",
"title": {
"en": "Atlas"
}
},
{
"id": "atlas-books",
"title": {
"en": "↳ Books"
},
"sub": true
},
{
"id": "atlas-templates",
"title": {
"en": "↳ Templates"
},
"sub": true
},
{
"id": "station",
"title": {
"en": "Station"
}
},
{
"id": "station-tester",
"title": {
"en": "↳ Tester"
},
"sub": true
},
{
"id": "station-datagen",
"title": {
"en": "↳ Datagen"
},
"sub": true
},
{
"id": "station-modelgen",
"title": {
"en": "↳ Modelgen"
},
"sub": true
},
{
"id": "station-graphgen",
"title": {
"en": "↳ Graphgen"
},
"sub": true
},
{
"id": "station-shuntgen",
"title": {
"en": "↳ Shuntgen"
},
"sub": true
},
{
"id": "station-databrowse",
"title": {
"en": "↳ Databrowse"
},
"sub": true
},
{
"id": "station-cabinets",
"title": {
"en": "↳ Cabinets"
},
"sub": true
},
{
"id": "components",
"title": {
"en": "Shared Components"
}
},
{
"id": "export",
"title": {
"en": "Export / Compile"
}
},
{
"id": "themes",
"title": {
"en": "Themes"
}
},
{
"id": "deployment",
"title": {
"en": "Deployment"
}
}
] ]

22
rig/.gitattributes vendored Normal file
View File

@@ -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

29
rig/.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# 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 was here for a generator that no longer exists. ctrl/Tiltfile
# is now a real, committed file that derives its values when Tilt parses it, so
# there is nothing generated to ignore.
# binaries pulled by `make deps-bundle` for the air-gapped installer image
vendor
# Overlays that live with no version control of their own, or clones of their own
# repos: rig reads them and never tracks them (docs/notes/overlay.md).
/local/
# A profile you activated (cp env.d/<name>.env.example env.d/<name>.env) is this
# machine's choice; only the examples are committed.
ctrl/env.d/*.env
# Only the default kit is committed; a kit for a local profile is this machine's.
standalone/*
!standalone/default/

219
rig/BOOTSTRAP.md Normal file
View File

@@ -0,0 +1,219 @@
# From a machine with nothing on it to an environment 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 an overlay running under
Tilt at the end of it.
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 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 check
cp ctrl/.env.example ctrl/.env
```
`check.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 the check 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/deps.sh install` directly on the host, and the installer
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.deps` exists
to kill — the installer 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 deps image # builds rig-deps:deps
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-deps:deps install dev
```
The image name follows the directory, like everything else here: in `rig`
it is `rig-deps`, in a copy called `other-rig` it is `other-rig-deps`. The
tag is `deps` (or `full`, below), not `latest`.
None of the four arguments are guessable, so:
- **`/:/host:ro`** — the installer 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 installer 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 installer 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 installer 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 deps image 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.
How the installer itself is tested — machine fixtures, clean containers, a
Workspace snapshot, a throwaway WSL distro — is in
[`docs/notes/installer-testing.md`](docs/notes/installer-testing.md).
## Prove the machine before blaming the project
```bash
make check
make cluster up
kubectl get nodes
```
`make check` re-runs every check — host, docker, toolchain, memory, ports — and
changes nothing. Run now, it should end with nothing left to do by hand, and that
is the point: it is the scoreboard, not the installer.
`make cluster up` builds rig's built-in defaults — 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 <names>` stops
clusters without deleting them; `docker start` brings them back untouched.
## Start an overlay
What runs lives outside rig, in an overlay — see
[`docs/notes/overlay.md`](docs/notes/overlay.md). Start from rig's own:
```bash
cp -r examples/starter local/myenv # local/ is gitignored by rig
echo 'OVERLAY=local/myenv' >> ctrl/.env
make check # shows the overlay, its cluster and ports
```
The cluster takes the overlay's folder name and the context becomes
`kind-<name>`, so there is nothing to edit for either. Replace the two example
components under `k8s/base/`, and add your images and resources to the
overlay's `Tiltfile`. Check the manifests before `kind` spends minutes on anything
— this renders the whole tree without a cluster and catches a broken patch
immediately:
```bash
kubectl kustomize local/myenv/k8s/overlays/dev
```
If the overlay is to be versioned, make `local/myenv` a repository of its own (rig
never tracks it), or keep it anywhere else and name it by path.
## Run it
```bash
make kind-up # idempotent create, then selects the context
make tilt-up # context + your assigned port
```
`tilt-up` passes `--context kind-<name>` 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.

101
rig/Makefile Normal file
View File

@@ -0,0 +1,101 @@
# Thin control Makefile: the subcommand is an argument (`make cluster down`); logic lives in ctrl/ scripts.
# make check | deps | cluster up | tilt | docs (`make help` lists all)
# Start with: make check && make deps && make cluster up
# Notes: docs/notes/Makefile.md
# Identity and ports, asked once of ctrl/ports.sh, read positionally (selftest pins the order):
# CLUSTER KUBECONTEXT HTTP HTTPS TILT REGISTRY MANIFESTS_DIR OVERLAY_DIR
# OVERLAY/CLUSTER given as make arguments are handed over explicitly: before make 4.4,
# $(shell) does not see them, and the context would follow the wrong environment.
FACTS := $(shell $(if $(OVERLAY),OVERLAY='$(OVERLAY)') $(if $(CLUSTER),CLUSTER='$(CLUSTER)') bash ctrl/ports.sh active 2>/dev/null)
SLUG := $(shell echo '$(notdir $(CURDIR))' | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-' | sed 's/^-*//; s/-*$$//')
# Fall back to the folder name, not empty, if ports.sh fails on a broken config.
CLUSTER := $(or $(word 1,$(FACTS)),$(SLUG))
KCTX := --context $(or $(word 2,$(FACTS)),kind-$(SLUG))
TILT_PORT := $(word 5,$(FACTS))
DEPSIMG := $(SLUG)-deps
# 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 (ctrl, docs, ...).
.PHONY: $(ARGS)
endif
.PHONY: help check deps cluster tilt docs selftest standalone \
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
# ── this machine ───────────────────────────────────────────────────────────
# Everything that looks and never changes anything: host, docker, toolchain,
# config, memory, ports, registry, addons. `check mem` goes deeper on memory —
# how far it really climbs, and the WSL .wslconfig backup/restore.
check: ## is this machine ready? [all] [mem [status|push|all|backup|restore]]
bash ctrl/check.sh $(ARGS)
# `deps image` is for a machine with nothing but Docker: the installer runs from
# the image instead — see BOOTSTRAP.md. `full` bakes every binary in.
deps: ## install the toolchain [core|dev] [image [full]]
ifeq ($(word 1,$(ARGS)),image)
docker build -f ctrl/Dockerfile.deps \
--target $(if $(filter full,$(ARGS)),deps-full,deps) \
-t $(DEPSIMG):$(if $(filter full,$(ARGS)),full,deps) .
else
bash ctrl/deps.sh install $(or $(ARGS),dev)
endif
# ── the cluster ────────────────────────────────────────────────────────────
# up also starts the registry and installs the profile's addons, and the ports
# derive from the folder name — there is nothing else to run first.
cluster: ## this env + the machine [up|down|reset|list|free]
bash ctrl/cluster.sh $(or $(ARGS),up)
# ── dev loop + docs ────────────────────────────────────────────────────────
docs: ## documentation [serve|graphs] (default serve)
bash ctrl/docs.sh $(or $(ARGS),serve)
# --port only when TILT_PORT resolved; the Tiltfile asks ports.sh for the rest itself.
tilt: ## dev loop [up|down] (default up)
cd ctrl && tilt $(or $(ARGS),up) $(KCTX) $(if $(filter down,$(ARGS)),,$(if $(TILT_PORT),--port $(TILT_PORT)))
# ── maintaining rig ────────────────────────────────────────────────────────
# The counterpart to check: that one asks about the MACHINE and never fails,
# this one asks about RIG and exits 1. Checks are written as the decisions they
# defend, so a failure names what is being undone.
selftest: ## does rig still do what it says? exits 1 if not [install]
bash ctrl/selftest.sh $(ARGS)
# The one-file versions of rig's tools, one folder per profile, for machines the
# full rig is not going to. Generated from rig as it is, never edited by hand;
# `check` is what selftest runs to catch a kit left behind by a change to rig.
standalone: ## single-file kits [write|check|export DIR] (default write)
bash ctrl/standalone.sh $(or $(ARGS),write)
# ── the shape every other project uses ─────────────────────────────────────
# Aliases matching other projects' kind-up / tilt-up; each calls the same script.
# Nothing else reads these names: rename, delete or add freely (and update .PHONY).
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. rig ships ctrl/Tiltfile, so they
# run — it deploys the examples in k8s/base until you replace them.
tilt-up: ## alias for `tilt up`
cd ctrl && tilt up $(KCTX) $(if $(TILT_PORT),--port $(TILT_PORT))
tilt-down: ## alias for `tilt down`
cd ctrl && tilt down $(KCTX)

200
rig/README.md Normal file
View File

@@ -0,0 +1,200 @@
# 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.
### Starting from plain Windows
Everything here is bash and runs *inside* a Linux shell, so on a Windows machine
that means WSL. Nothing in rig installs WSL, and nothing will: `wsl --install`
enables Windows features and requires a reboot, which is not something a script
should do to a machine on your behalf — and there is no tested undo for it.
From an elevated PowerShell or Command Prompt, once:
```powershell
wsl --install
```
Then reboot and open the Linux shell it installed.
**If you cloned this on the Windows side, copy it into WSL before carrying on.**
WSL can reach the Windows drives at `/mnt/c`, and working from there mostly
functions — slowly — but file watching does not: that filesystem raises no
inotify events, so anything watching for edits silently stops seeing them.
```bash
cp -r /mnt/c/Users/<you>/rig ~/rig
cd ~/rig
```
`make deps` reports it if you are running from `/mnt/...`. Then carry on below.
If it fails, the usual causes give unhelpful messages:
| symptom | cause |
| --- | --- |
| "the virtual machine could not be started" | virtualization disabled in BIOS/UEFI |
| the command is not recognised | Windows build too old — needs 2004 or later |
| the install starts, then nothing works | a reboot is still pending |
Running the scripts from **Git Bash, MSYS or Cygwin does not work** — those look
close enough to a Linux shell to get started and then fail without `/proc` or a
docker socket. `ctrl/deps.sh` detects that and says so rather than letting you
find out the slow way.
## 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.
Why the code is the way it is — the reasoning, measurements and gotchas — lives
in [`docs/notes/`](docs/notes/), one file per script, so the code keeps short comments.
## Then
```bash
make check # is this machine ready? short; `make check all` for every detail
make deps # install the toolchain (add `core` on a managed machine)
make cluster up # cluster + registry + the profile's addons
```
That is the whole setup. `make cluster up` also starts this environment's local
registry and wires it into the node, so an image built locally is pullable by the
cluster without going near docker.io. `make check` shows its port, among
everything else:
```bash
make check # ... registry localhost:<port> (running)
docker build -t localhost:<port>/app:1 .
docker push localhost:<port>/app:1
kubectl --context kind-$(basename $PWD) run app --image=localhost:<port>/app:1
```
The port block is derived from the directory name, so two copies of rig never
collide — nothing to configure. `make check all` lists it; `bash ctrl/ports.sh persist`
pins it into `ctrl/.env` if you want it fixed:
```bash
make cluster list # every cluster on this machine, with memory
make cluster free # stop the others if memory is tight
make cluster down # remove this cluster and its registry
```
**The verbs are yours to change.** `cluster` is the script — `ctrl/cluster.sh`
and every spelling above is a `Makefile` target that calls it. `make kind-up` is
an alias for `make cluster up`, kept because the other projects on this machine
answer to that spelling and muscle memory spans repos rather than stopping at
one. Nothing outside the `Makefile` reads these names, so rename them, drop the
ones you never type, or add whatever your own projects already say: each alias
is two lines at the bottom of the file, calling the same script the canonical
target does.
**`make tilt` works on a fresh copy, unedited.** rig's `ctrl/Tiltfile` does
rig's part — identity, context guard, registry, the manifests — and then includes
the overlay's own Tiltfile. With no overlay named that is `examples/starter`, so
the dev loop comes up with its two examples running and nothing to configure.
It hardcodes nothing. It asks `ctrl/ports.sh active` for this environment's
cluster name, kube context, ports and paths — the same values every other rig
script resolves through `ctrl/lib/config.sh` — so a copied and renamed rig, or a
moved overlay, deploys into its own cluster with no edits.
`make help` lists every target. `make selftest` checks rig itself, the installer's
detection included; `make selftest install` runs the installer on clean containers
([`docs/notes/installer-testing.md`](docs/notes/installer-testing.md)).
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 installer container and carries on to scaffolding and running
a new project.
## What runs is an overlay
rig is the machine: toolchain, cluster, registry, port block, the dev loop's
plumbing. What runs on it is an **overlay** — one folder, outside rig's version
control, holding a use case: its settings (`rig.env`), its manifests
(`k8s/overlays/dev`), its images and Tiltfile, its addons, its kind config if it
needs its own. rig reads it and never writes into it. See
[`docs/notes/overlay.md`](docs/notes/overlay.md).
```bash
cp -r examples/starter local/myenv # local/ is gitignored
OVERLAY=local/myenv make cluster up # or OVERLAY=local/myenv in ctrl/.env
OVERLAY=local/myenv make tilt
```
An overlay can also be a repo of its own, anywhere, or a project folder that
carries rig at `<project>/rig/` with a three-line forwarding Makefile.
## One environment per folder
Cluster name, kubectl context, image tags and the host port block all derive
from a folder name — the overlay's when one is named, else rig's own — so copies
never collide and neither one's teardown can touch the other. Two overlays run
side by side from one rig; two copies of rig do too.
## Profiles
**rig needs no profile.** With none named it runs on built-in defaults: one node,
no addons, a local registry, the newest Kubernetes version it pins. A profile is
an optional file in `ctrl/env.d/`, named by `PROFILE`, that says how this machine
reaches the world. rig ships two as **examples**; copy one to use it (the copy is
gitignored):
| example | what it changes |
| --- | --- |
| `mirror.env.example` | images through a pull-through cache of an internal registry |
| `offline.env.example` | air-gapped: everything from a preloaded local registry |
```bash
cp ctrl/env.d/mirror.env.example ctrl/env.d/mirror.env
PROFILE=mirror make cluster up
```
The layers, weakest first: built-in defaults < `ctrl/versions.env` < the profile
< the overlay's `rig.env` < `ctrl/.env` < your command line.
The **cluster itself** is one file: the overlay's `kind-config.yaml.tpl` if it has
one, else rig's `ctrl/k8s/kind-config.yaml.tpl` (one node). To change it — more
nodes, other port mappings, mounts — edit it and `make cluster reset`. The node
count is read back out of it, so there is nothing to drift.
## Addons
Each addon is its own idempotent script, and `ADDONS` names the ones to install,
in order. Adding one is adding a file — there is no dispatcher to edit. An
overlay's `addons/<name>.sh` is found before rig's own.
**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 is often the point.
Services are reached through MetalLB and `type: LoadBalancer`, which carries no
such constraint and is also what a real cluster does.
rig's own addons make the cluster work:
| 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 |
What a workload needs — a database, a cache, a scheduler — belongs to its
overlay. [`examples/data`](examples/data/) carries postgres, redis and airflow as
plain manifests (no helm: a chart repo is a network dependency), with passwords
generated on first install and kept across re-runs:
```bash
OVERLAY=examples/data make cluster up
```

68
rig/STALE.md Normal file
View File

@@ -0,0 +1,68 @@
# rig — withdrawn assumptions
**Everything in this file is no longer true.**
It exists so the live docs stay short and a withdrawn assumption cannot quietly
return: each entry carries a **check**, and `ctrl/selftest.sh` runs every one of
them under "withdrawn stays withdrawn". A retraction that is only prose is one
nobody re-reads.
- **Do not restate these** in a plan, a README or a comment; point here (`✖ S2`).
- **Ids are stable.**
- **Only withdrawn things belong here.** A warning that is still actionable is a
live rule and stays where it is.
---
**✖ S1 — "rig supplies `ctrl/Tiltfile` but does not own it: replace the examples and
add your images in its marked sections."** *(ctrl/Tiltfile, README, 2026-09-13)*
Withdrawn 2026-09-22. Every project that used rig edited rig's own file, so no
update to rig could land without merging those edits by hand. rig's `ctrl/Tiltfile`
is now rig's: identity, context guard, registry, manifests, namespaces. The
workload's half is the overlay's own `Tiltfile`, which rig's includes.
**Check:** `ctrl/Tiltfile` includes the overlay's Tiltfile and has no "Images" section
of its own.
**✖ S2 — "A second environment is a copy of rig renamed after it (`../<name>-rig`), and
its use case is written into the copy."** *(README, BOOTSTRAP, .gitignore, 2026-08)*
Withdrawn 2026-09-22. A use case is an overlay, kept outside rig's version control
(`docs/notes/overlay.md`); rig itself is replaced as a whole. Copying rig still gives
a separate environment, but carries no use case of its own.
**Check:** rig's `.gitignore` ignores `/local/`; no `acme` example name remains in rig.
**✖ S3 — "rig's addons include the services a workload needs (a database, a cache, a
scheduler), described in the host project's vocabulary."** *(ctrl/addons/, README,
versions.env, 2026-08)* Withdrawn 2026-09-22. Which services a workload needs is
not rig's business. `ctrl/addons/` holds what makes a cluster work; workload addons
live with overlays, and `examples/data` carries them as an example.
**Check:** `ctrl/addons/` holds exactly cert-manager, metallb and metrics-server;
`versions.env` pins no workload image.
**✖ S4 — "The dev loop's namespace is named after the cluster."** *(ctrl/Tiltfile,
2026-09-13)* Withdrawn 2026-09-22. The Tiltfile created and grouped
`<CLUSTER>:namespace` while the example manifests declared `rig`, so every copy not
named `rig` stopped at load: `No object identified by the fragment
"acme-rig:namespace"`. The first real use worked around it with a Namespace named
`to_be_replaced` that its overlay renamed. The Tiltfile now creates the namespaces
the manifests use and groups the ones they declare.
**Check:** `ctrl/Tiltfile` does not build a namespace name from `CLUSTER`.
**✖ S5 — "`MANIFESTS_DIR` defaults to `ctrl/k8s/overlays/dev`, rig's own examples."**
*(lib/config.sh, .env.example, 2026-09-13)* Withdrawn 2026-09-22. The default is the
overlay's `k8s/overlays/dev`; rig's examples are `examples/starter`. The old value,
still pinned by older `.env` files, is ignored while that folder does not exist, and
`make check` says to delete it.
**Check:** `ctrl/k8s/overlays` does not exist; `.env.example` does not set
`MANIFESTS_DIR`.
**✖ S6 — "The example profiles are `client`, `data` and `offline`."** *(ctrl/env.d/,
2026-09-17)* Withdrawn 2026-09-22. "client" names the customer, not a registry
mode: the example is `mirror`. `data` was a workload, not a way of reaching the
world: it is the `examples/data` overlay.
**Check:** `ctrl/env.d/` holds no `client` or `data` example.
**✖ S7 — "BOOTSTRAP spans three repos: rig prepares the machine, the house repos own the
project's shape and everything after local."** *(BOOTSTRAP.md, 2026-08)* Withdrawn
2026-09-22. rig's docs describe rig. The house scaffold and registration sections
moved out of rig; BOOTSTRAP now ends with starting an overlay.
**Check:** no house path or host name (`semester`, `local.ar`) in rig.

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

@@ -0,0 +1,43 @@
# Machine-local config. Copy to ctrl/.env (gitignored) and edit.
# Cluster SHAPE: an optional profile in ctrl/env.d/. Architecture MODEL: arch/<name>.json.
# Notes: docs/notes/env.md
# A profile in ctrl/env.d/ to build. Empty means rig's built-in defaults, which
# need no profile at all. Copy an env.d/*.env.example to <name>.env to add one.
PROFILE=
# The overlay: one folder, outside rig's version control, holding what runs —
# its settings (rig.env), manifests, addons, Tiltfile. Relative to rig's folder,
# or absolute. Unset: rig's own examples/starter. See docs/notes/overlay.md.
# OVERLAY=local/<name>
# Cluster name; the kubectl context becomes kind-<CLUSTER>.
# LEAVE UNSET: it defaults to this folder's name, which keeps the folder copyable.
# CLUSTER=
# Host ports. LEAVE UNSET — derived from the directory name (see ctrl/ports.sh).
# `bash ctrl/ports.sh persist` pins them here; set a value only to override.
# HTTP_PORT=
# HTTPS_PORT=
# TILT_PORT=
# REGISTRY_PORT=
# Where the manifests live. Leave unset: the overlay's k8s/overlays/dev.
# MANIFESTS_DIR=../platform-manifests/overlays/dev
# Where the installer fetches the pinned binaries from:
# upstream (needs internet) | artifactory (generic repo) | baked (in the image)
DEPS_SOURCE=upstream
DEPS_ARTIFACTORY_URL=
# --- Registry -------------------------------------------------------------
# Mode comes from the profile (REGISTRY_MODE). Secrets required for mirror/remote:
REGISTRY_REMOTE_URL=
REGISTRY_USER=
REGISTRY_PASSWORD=
# Corporate root CA, if Artifactory is fronted by an internal CA.
# 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.)

36
rig/ctrl/Dockerfile.deps Normal file
View File

@@ -0,0 +1,36 @@
# Toolchain installer image: installs the pinned toolchain onto the host; Docker is the only prerequisite.
# docker build -f ctrl/Dockerfile.deps --target deps -t <slug>-deps .
# docker build -f ctrl/Dockerfile.deps --target deps-full -t <slug>-deps:full .
# Notes: docs/notes/Dockerfile.deps.md
FROM debian:trixie-slim AS deps
# curl: fetch and verify; graphviz + python3: diagrams. docker-cli, NOT docker.io
# (which lacks the `docker` binary under --no-install-recommends).
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/*
# The installer is the generated one-file standalone kit, pins frozen in.
ARG PROFILE=default
WORKDIR /work
COPY standalone/${PROFILE}/rigdeps.sh /work/rigdeps.sh
RUN chmod +x /work/rigdeps.sh
# Defaults; every one is overridable with -e at run time.
ENV DEPS_SOURCE=upstream \
OUT_BIN=/out/bin \
HOST_ROOT=/host
ENTRYPOINT ["/work/rigdeps.sh"]
CMD ["install"]
# ---------------------------------------------------------------------------
# deps-full — same image, binaries and the addons' manifests baked in, works with no network.
FROM deps AS deps-full
RUN /work/rigdeps.sh fetch --to /opt/rig/bin \
&& /work/rigdeps.sh manifests --to /opt/rig/manifests
ENV DEPS_SOURCE=baked \
BAKED_BIN=/opt/rig/bin \
BAKED_MANIFESTS=/opt/rig/manifests

67
rig/ctrl/Tiltfile Normal file
View File

@@ -0,0 +1,67 @@
# The dev loop, rig's half. `make tilt` from rig's folder, or from an overlay's forwarder.
# rig owns this file: who we are, the context guard, the registry, the overlay's manifests.
# The workload's half is the overlay's own Tiltfile, included at the end; edit that one.
# Notes: docs/notes/Tiltfile.md
# ── who we are, and on which ports ─────────────────────────────────────────
# Asked of ctrl/ports.sh (via lib/config.sh), never recomputed here in Starlark.
_facts = str(local('bash ports.sh active', quiet=True)).split()
CLUSTER = _facts[0]
CTX = _facts[1]
HTTP = _facts[2]
HTTPS = _facts[3]
TILT = _facts[4]
REGISTRY = _facts[5]
# Absolute paths, or '-' when there is none.
MANIFESTS = '' if _facts[6] == '-' else _facts[6]
OVERLAY = '' if _facts[7] == '-' else _facts[7]
# ── refuse to deploy into the wrong cluster ────────────────────────────────
# Tilt fixes the context before parsing this file, so it can only be refused here.
# `make tilt` passes --context; this catches a bare `tilt up`.
allow_k8s_contexts(CTX)
if k8s_context() != CTX:
fail("Wrong kubectl context: '%s'. This is %s — run: make tilt, or tilt up --context %s"
% (k8s_context(), CLUSTER, CTX))
# ── images go to this environment's own registry ───────────────────────────
# Fail closed: name the registry rather than let Tilt infer it, or a miss pushes
# an unqualified image to docker.io.
default_registry('localhost:' + REGISTRY)
# ── the overlay's manifests ────────────────────────────────────────────────
# Every namespace they use must exist before anything lands in it, and kustomize
# does not order resources, so create them here (idempotent). The Namespaces they
# declare are grouped as 'infra', whatever they are named.
if MANIFESTS:
_yaml = kustomize(MANIFESTS)
k8s_yaml(_yaml)
_declared = []
_namespaces = {}
for _o in decode_yaml_stream(_yaml):
if not _o:
continue
_md = _o.get('metadata') or {}
if _o.get('kind') == 'Namespace':
_declared.append(_md.get('name'))
_namespaces[_md.get('name')] = True
elif _md.get('namespace'):
_namespaces[_md.get('namespace')] = True
for _ns in sorted(_namespaces.keys()):
local('kubectl --context %s create namespace %s --dry-run=client -o yaml | kubectl --context %s apply -f -'
% (CTX, _ns, CTX), quiet=True)
if _declared:
k8s_resource(objects=[_n + ':namespace' for _n in _declared], new_name='infra')
# ── the workload's half: the overlay's Tiltfile ────────────────────────────
# Included, so its relative paths resolve from the overlay's own folder. It reads
# these facts with os.getenv and never needs a path back into rig.
os.putenv('RIG_CLUSTER', CLUSTER)
os.putenv('RIG_CONTEXT', CTX)
os.putenv('RIG_HTTP_PORT', HTTP)
os.putenv('RIG_HTTPS_PORT', HTTPS)
os.putenv('RIG_TILT_PORT', TILT)
os.putenv('RIG_REGISTRY', 'localhost:' + REGISTRY)
os.putenv('RIG_OVERLAY_DIR', OVERLAY)
if OVERLAY and os.path.exists(OVERLAY + '/Tiltfile'):
include(OVERLAY + '/Tiltfile')

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

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

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

@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# cert-manager plus a self-signed cluster issuer (offline local CA).
# Notes: docs/notes/addons.md
set -euo pipefail
cd "${RIG_CTRL:-$(dirname "$0")/..}"
source ./lib/config.sh
load_config
K="kubectl --context ${KUBECONTEXT}"
if $K get deployment -n cert-manager cert-manager >/dev/null 2>&1; then
echo " already installed"
else
# The pinned manifest, verified on disk — never a URL applied directly.
manifest=$(bash ./deps.sh manifest CERT_MANAGER)
$K apply -f "$manifest"
fi
echo " waiting for cert-manager..."
$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"

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

@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# MetalLB — makes `Service type: LoadBalancer` actually get an address.
# The pool is derived from the kind Docker network at install time.
# Notes: docs/notes/addons.md
set -euo pipefail
cd "${RIG_CTRL:-$(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
# The pinned manifest, verified on disk — never a URL applied directly.
manifest=$(bash ./deps.sh manifest METALLB)
$K apply -f "$manifest"
fi
# `rollout status`, not `kubectl wait`: wait errors out while the pod doesn't exist yet.
echo " waiting for the controller..."
$K rollout status deployment/controller -n metallb-system --timeout=240s
$K rollout status daemonset/speaker -n metallb-system --timeout=240s
# The webhook rejects IPAddressPools until it is actually serving, and it comes
# up a moment after the pod is Ready — so retry rather than fail the whole run
# on a race that resolves itself in seconds.
echo " configuring the address pool"
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if $K apply -f - >/dev/null 2>&1 <<YAML
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: default
namespace: metallb-system
spec:
addresses:
- ${pool_start}-${pool_end}
---
# Layer 2 mode: one node answers ARP for each address. No BGP peer needed, which
# is what makes this work on a laptop.
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: default
namespace: metallb-system
spec:
ipAddressPools:
- default
YAML
then
echo " pool ready: ${pool_start}-${pool_end}"
exit 0
fi
sleep 3
done
echo " ! the pool was rejected after 10 attempts — is the webhook up?" >&2
$K get pods -n metallb-system >&2
exit 1

View File

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

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

@@ -0,0 +1,216 @@
#!/usr/bin/env bash
# Readiness check: is this machine ready to run rig? Reports and instructs; never fixes.
# Usage: check.sh [all | mem [status|push|all|backup|restore]] (all = every detail)
# Notes: docs/notes/check.md
set -euo pipefail
cd "$(dirname "$0")"
# `check mem` goes deeper on memory than the summary below: how far allocation
# really climbs, and the WSL .wslconfig backup/restore.
if [ "${1:-}" = mem ]; then
shift
exec bash ./mem.sh "${@:-status}"
fi
# Compact by default: facts only with `all`; problems (!) always print.
VERBOSE=""
if [ "${1:-}" = all ]; then VERBOSE=1; fi
fact() { if [ -n "$VERBOSE" ]; then echo "$@"; fi; }
bash ./deps.sh detect ${VERBOSE:+all}
source ./lib/config.sh
load_config
# A /proc/meminfo field in MB, 0 if absent. MEMINFO/OVERCOMMIT_FILE override for testing.
mb_of() {
awk -v k="$1:" '$1 == k { printf "%d", $2 / 1024; found = 1 }
END { if (!found) printf "0" }' "${MEMINFO:-/proc/meminfo}"
}
# NODE_MB (cost of one node) comes from load_config in lib/config.sh; do not copy it here.
# Every running container's working set in MB, tagged with its kind cluster ('-' if none).
container_mb() {
docker info >/dev/null 2>&1 || return 0
awk -F'\t' '
FILENAME == ARGV[1] { cl[$1] = ($2 == "" ? "-" : $2); if ($2 != "") isc[$2] = 1; next }
{
grp = ($1 in cl ? cl[$1] : "-")
# A kind cluster'"'"'s local registry is a plain container with no kind
# label, named <cluster>-registry, so on its own it would read as a
# stranger. It belongs to its cluster — but only if that cluster exists:
# a registry whose cluster is gone is a genuine stray, and says so.
if (grp == "-" && $1 ~ /-registry$/) {
base = $1; sub(/-registry$/, "", base)
if (base in isc) grp = base
}
split($2, u, " "); v = u[1]; mb = 0
if (v ~ /GiB$/) { sub(/GiB$/, "", v); mb = v * 1024 }
else if (v ~ /MiB$/) { sub(/MiB$/, "", v); mb = v }
else if (v ~ /KiB$/) { sub(/KiB$/, "", v); mb = v / 1024 }
else if (v ~ /B$/) { sub(/B$/, "", v); mb = v / 1048576 }
printf "%d\t%s\t%s\n", mb, grp, $1
}
' <(docker ps --format '{{.Names}}\t{{.Label "io.x-k8s.kind.cluster"}}' 2>/dev/null) \
<(docker stats --no-stream --format '{{.Name}}\t{{.MemUsage}}' 2>/dev/null)
}
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
}
echo
echo "rig"
echo " cluster ${CLUSTER} (${KUBECONTEXT}) profile ${PROFILE_NAME}, ${NODES} node(s), registry ${REGISTRY_MODE}"
if [ -n "${OVERLAY:-}" ]; then
echo " overlay $(basename "$(_abs_from_ctrl "$OVERLAY_DIR")") ($(_abs_from_ctrl "$OVERLAY_DIR"))"
elif [ -n "$OVERLAY_DIR" ]; then
fact " overlay none named — rig's own ${OVERLAY_DIR}"
fi
if [ -n "$VERBOSE" ] && [ -n "$OVERLAY_DIR" ]; then
ov=$(_from_ctrl "$OVERLAY_DIR") pieces=""
for piece in rig.env k8s/overlays/dev kind-config.yaml.tpl addons Tiltfile; do
[ -e "$ov/$piece" ] && pieces+="$piece "
done
echo " provides: ${pieces:-nothing rig reads}"
fi
fact " manifests ${MANIFESTS_DIR:-none}"
fact " kind config ${KIND_CONFIG}"
fact " ingress ${INGRESS_MODE}"
if [ ! -f ./.env ]; then
fact " .env none — built-in defaults (cp ctrl/.env.example ctrl/.env to set values)"
fi
if [ -n "${STALE_MANIFESTS_DIR:-}" ]; then
echo " ! .env MANIFESTS_DIR=${STALE_MANIFESTS_DIR} is the old default; rig's examples moved"
echo " to examples/ — delete that line from ctrl/.env (ignored until then)"
fi
# registry.sh points containerd at certs.d, which only works if the kind config says so,
# and a kind config is fixed at creation: a project's own file that drops it fails silently.
if [ "$REGISTRY_MODE" != none ] && ! grep -q 'config_path *= *"/etc/containerd/certs.d"' "$KIND_CONFIG"; then
echo " ! kind ${KIND_CONFIG} lacks the containerd config_path patch that registry mode"
echo " '${REGISTRY_MODE}' needs — copy it from ctrl/k8s/kind-config.yaml.tpl"
fi
# ── memory: does this cluster fit right now? Warns; never blocks. ──────────
total_mb=$(mb_of MemTotal)
avail_mb=$(mb_of MemAvailable)
swap_used_mb=$(( $(mb_of SwapTotal) - $(mb_of SwapFree) ))
overcommit=$(cat "${OVERCOMMIT_FILE:-/proc/sys/vm/overcommit_memory}" 2>/dev/null || echo '?')
need_mb=$(( NODES * NODE_MB ))
rows=$(container_mb)
# If our cluster is already up, its memory is already out of MemAvailable: need nothing more.
ours_mb=$(awk -F'\t' -v c="$CLUSTER" '$2 == c { s += $1 } END { print s + 0 }' <<< "$rows")
still_mb=$(( ours_mb > 0 ? 0 : need_mb ))
headroom=$(( avail_mb - still_mb ))
# The biggest things holding memory, other than this cluster: kind clusters summed, the rest by name.
others=$(awk -F'\t' -v c="$CLUSTER" '
$2 != c && $2 != "-" && $2 != "" { k["kind cluster \x27" $2 "\x27"] += $1 }
$2 == "-" { k["container \x27" $3 "\x27"] += $1 }
END { for (n in k) printf "%d\t%s\n", k[n], n }' <<< "$rows" | sort -rn)
if [ "$still_mb" -eq 0 ] && [ "$headroom" -ge 512 ]; then
printf " memory up, holding %d MB — %d MB headroom for what you deploy\n" "$ours_mb" "$headroom"
elif [ "$still_mb" -eq 0 ]; then
printf " ! memory up, but only %d MB headroom for anything you deploy\n" "$headroom"
elif [ "$headroom" -ge 512 ]; then
printf " memory fits — ~%d MB for %s node(s), %d MB headroom\n" "$need_mb" "$NODES" "$headroom"
elif [ "$headroom" -ge 0 ]; then
printf " ! memory fits, but only %d MB headroom (~%d MB for %s node(s))\n" "$headroom" "$need_mb" "$NODES"
else
printf " ! memory does not fit: ~%d MB needed, %d MB available\n" "$still_mb" "$avail_mb"
# Two failures with opposite fixes, and telling them apart is the point.
if [ "$still_mb" -le "$total_mb" ]; then
echo " something else holds it (below) — stopping that helps, a bigger VM would not."
if grep -q 'kind cluster' <<< "$others"; then
echo " 'make cluster free' stops the other kind clusters. It stops, never deletes."
fi
else
echo " the machine itself is too small: ${total_mb} MB total."
fi
fi
if [ -n "$others" ] && { [ -n "$VERBOSE" ] || [ "$headroom" -lt 512 ]; }; then
echo " held elsewhere:"
head -6 <<< "$others" | awk -F'\t' '{ printf " %6d MB %s\n", $1, $2 }'
n_others=$(wc -l <<< "$others")
if [ "$n_others" -gt 6 ]; then
echo " ... and $((n_others - 6)) more"
fi
fi
if [ "$swap_used_mb" -gt 0 ]; then
fact " ${swap_used_mb} MB already in swap, which 'available' does not count: expect slow before failing"
fi
if [ "$overcommit" = "1" ]; then
fact " overcommit=1: allocations never fail, so read 'fits' as a ceiling (OOM killer settles up)"
fi
# ── ports: checked before creation; docker reports a clash only halfway through. ──
# Ports held by our own cluster are not clashes. Second grep, not `tr -d ':->'` (a tr range).
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 list="" mine=0
for entry in "HTTP:${HTTP_PORT}" "HTTPS:${HTTPS_PORT}" \
"TILT:${TILT_PORT}" "REGISTRY:${REGISTRY_PORT}"; do
name="${entry%%:*}"; p="${entry#*:}"
[ -n "$p" ] || continue
list+="$p "
if ! port_busy "$p"; then
fact "$(printf " %-9s %-6s free" "$name" "$p")"
elif echo "$ours" | grep -qx "$p"; then
mine=1
fact "$(printf " %-9s %-6s in use by this environment's cluster" "$name" "$p")"
else
printf " ! ports %s %s IN USE by something else\n" "$name" "$p"
clash=1
fi
done
if [ "$clash" -eq 1 ]; then
echo " override it in ctrl/.env (e.g. HTTP_PORT=21080), or rename this directory"
elif [ "$mine" -eq 1 ]; then
echo " ports ${list% } held by this cluster"
else
echo " ports ${list% } free"
fi
if [ -n "${OVERLAY:-}" ]; then
fact " derived from the overlay's folder name"
else
fact " derived from the directory name; pin them: bash ctrl/ports.sh persist"
fi
# ── what `make cluster up` wires in beside the cluster ─────────────────────
REG_NAME="${CLUSTER}-registry"
if state=$(docker inspect -f '{{.State.Status}}' "$REG_NAME" 2>/dev/null); then
echo " registry localhost:${REGISTRY_PORT} ($state)"
else
fact " registry no container yet — 'make cluster up' starts it"
fi
echo " addons ${ADDONS:-none}"
if [ -n "$VERBOSE" ]; then
bash ./addons.sh list | sed -n '3,$p' | sed 's/^/ /'
fi
# The CA reaches three places and only one of them is ours. Report the other two.
if [ -n "${REGISTRY_CA_FILE:-}" ]; then
if [ ! -r "$REGISTRY_CA_FILE" ]; then
echo " ! CA REGISTRY_CA_FILE not readable: $REGISTRY_CA_FILE"
else
fact " CA $REGISTRY_CA_FILE"
host="${REGISTRY_REMOTE_URL#*://}"; host="${host%%/*}"
if [ -n "$host" ] && [ ! -f "/etc/docker/certs.d/${host}/ca.crt" ]; then
echo " ! CA 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

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

@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# Cluster lifecycle (convergent, not exit-early), plus what else runs on this machine.
# Usage: cluster.sh up | down | reset | list | free
# Notes: docs/notes/cluster.md
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
load_config
up() {
if kind get clusters 2>/dev/null | grep -qx "$CLUSTER"; then
echo "cluster '$CLUSTER' exists — converging"
else
# Say what this profile locks in BEFORE spending minutes building it:
# the kind config is fixed at creation and cannot be changed later.
echo "creating cluster '$CLUSTER' from profile '$PROFILE_NAME'"
echo " overlay ${OVERLAY_DIR:-none}"
echo " kind config ${KIND_CONFIG}"
echo " nodes $NODES"
echo " image $NODE_IMAGE"
echo " ingress $INGRESS_MODE"
echo " registry $REGISTRY_MODE"
echo " (fixed at creation — edit the kind config, then 'make cluster reset')"
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 node count or port mappings.
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

848
rig/ctrl/deps.sh Executable file
View File

@@ -0,0 +1,848 @@
#!/usr/bin/env bash
# rig:standalone rigdeps detect
# Toolchain installer: detect the host, install pinned tools into $OUT_BIN, report
# host actions it will not perform (no sudo, no apt). Usually via `make deps`.
# Usage: deps.sh [detect [all] | list | verify [core|dev] | fetch [core|dev] [--to DIR] | install [core|dev]
# | manifest NAME | manifests [--to DIR] | snapshot [DIR]]
# Notes: docs/notes/deps.md
set -euo pipefail
# Keep the caller's cwd so a relative --to resolves there, not against ctrl/.
INVOKED_FROM="$PWD"
cd "$(dirname "$0")"
# Pins arrive through load_config, not by sourcing versions.env, so `make
# standalone` can freeze them in.
source ./lib/config.sh
load_config
# 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:-/}"
# A host fixture (docs/notes/installer-testing.md) is a root whose kernel files stand in
# for this machine's; UNAME_S does the same for the one fact a file cannot carry.
if [ "$HOST_ROOT" != / ]; then
if [ -r "$HOST_ROOT/proc/meminfo" ]; then MEMINFO="${MEMINFO:-$HOST_ROOT/proc/meminfo}"; fi
if [ -r "$HOST_ROOT/proc/sys/vm/overcommit_memory" ]; then
OVERCOMMIT_FILE="${OVERCOMMIT_FILE:-$HOST_ROOT/proc/sys/vm/overcommit_memory}"
fi
fi
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=()
# Facts print only with VERBOSE (`detect all`); problems (! and -) always print.
fact() { if [ -n "${VERBOSE:-}" ]; then echo "$@"; fi; }
# Host FILES are read through $HOST_ROOT; kernel facts are shared with the container.
# A /proc/meminfo field in MB, 0 if absent. MEMINFO overrides the source for testing.
mb_of() {
awk -v k="$1:" '$1 == k { printf "%d", $2 / 1024; found = 1 }
END { if (!found) printf "0" }' "${MEMINFO:-/proc/meminfo}"
}
host_file() {
local p="${1#/}"
if [ "$HOST_ROOT" != "/" ] && [ -e "$HOST_ROOT/$p" ]; then
echo "$HOST_ROOT/$p"
else
echo "/$p"
fi
}
# ── the tools this script itself needs ─────────────────────────────────────
arch() {
case "$(uname -m)" in
x86_64|amd64) echo amd64 ;;
aarch64|arm64) echo arm64 ;;
*) uname -m ;;
esac
}
# Pins are amd64 only: refuse elsewhere and print how to get the right checksums.
require_amd64() {
local a; a=$(arch)
[ "$a" = "amd64" ] && return 0
cat >&2 <<EOF
This machine is ${a} ($(uname -m)); every pin in this script is linux/amd64.
Nothing here would run, so it does not download. To make an ${a} version, the
URLs need the ${a} artifact and the checksums need to come from each project's
own published list — not from these values, and not from a download you did:
curl -sSL https://github.com/kubernetes-sigs/kind/releases/download/${KIND_VERSION}/checksums.txt
curl -sSL https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${a}/kubectl.sha256
curl -sSL https://github.com/tilt-dev/tilt/releases/download/v${TILT_VERSION}/checksums.txt
curl -sSL https://github.com/tilt-dev/ctlptl/releases/download/v${CTLPTL_VERSION}/checksums.txt
curl -sSL https://github.com/jqlang/jq/releases/download/jq-${JQ_VERSION}/sha256sum.txt
Edit the pinned block at the top of this file with what those print.
EOF
exit 1
}
DL=""
pick_downloader() {
if command -v curl >/dev/null 2>&1; then DL=curl
elif command -v wget >/dev/null 2>&1; then DL=wget
else
echo "neither curl nor wget is installed, so nothing can be downloaded." >&2
echo "Install one first: $(pkg_install_cmd curl)" >&2
exit 1
fi
}
download() {
local url="$1" out="$2"
case "$DL" in
curl) curl -fsSL --retry 3 -o "$out" "$url" ;;
wget) wget -q --tries=3 -O "$out" "$url" ;;
esac
}
SHA=""
pick_sha() {
if command -v sha256sum >/dev/null 2>&1; then SHA=sha256sum
elif command -v shasum >/dev/null 2>&1; then SHA="shasum -a 256"
else
echo "no sha256sum and no shasum — downloads could not be verified." >&2
echo "Refusing to install unverified binaries." >&2
exit 1
fi
}
# ── package manager, for the instructions only ─────────────────────────────
# Never runs one; names the right one so reported actions are pasteable.
pkg_install_cmd() {
local pkg="$1"
if command -v apt-get >/dev/null 2>&1; then echo "sudo apt-get update && sudo apt-get install -y $pkg"
elif command -v dnf >/dev/null 2>&1; then echo "sudo dnf install -y $pkg"
elif command -v yum >/dev/null 2>&1; then echo "sudo yum install -y $pkg"
elif command -v zypper >/dev/null 2>&1; then echo "sudo zypper install -y $pkg"
elif command -v apk >/dev/null 2>&1; then echo "sudo apk add $pkg"
else echo "install '$pkg' with this system's package manager"
fi
}
docker_pkg() {
# Debian and Ubuntu call it docker.io; the RPM distros call it docker.
if command -v apt-get >/dev/null 2>&1; then echo docker.io; else echo docker; fi
}
# ── detect ─────────────────────────────────────────────────────────────────
# Windows outside WSL (Git Bash, MSYS, Cygwin) fails confusingly; name it instead.
require_linux() {
case "${UNAME_S:-$(uname -s)}" in
MINGW*|MSYS*|CYGWIN*)
cat >&2 <<'EOF'
This has to run inside WSL, not Git Bash / MSYS / Cygwin.
If WSL is not installed yet, from an elevated PowerShell or Command Prompt:
wsl --install
That enables Windows features and needs a reboot, so it is not something this
script will do for you. Afterwards, open the Linux shell it installs and run
this from there.
See "Starting from plain Windows" in README.md.
EOF
exit 1 ;;
esac
}
is_wsl() { grep -qi microsoft "$(host_file /proc/version)" 2>/dev/null; }
detect() {
echo "host"
fact " kernel $(uname -r)"
local osr distro=""; osr=$(host_file /etc/os-release)
[ -r "$osr" ] && distro=$(sed -n 's/^PRETTY_NAME="\(.*\)"/\1/p' "$osr")
echo " distro ${distro:-unknown} $(arch), $(if is_wsl; then echo WSL; else echo native linux; fi)"
# In MB (whole GB rounds away too much). Facts only; check.sh judges sufficiency.
local total_mb avail_mb swap_total_mb swap_used_mb om
total_mb=$(mb_of MemTotal)
avail_mb=$(mb_of MemAvailable)
swap_total_mb=$(mb_of SwapTotal)
swap_used_mb=$(( swap_total_mb - $(mb_of SwapFree) ))
printf " memory %d MB total, %d MB available%s\n" "$total_mb" "$avail_mb" \
"$(if [ "$swap_used_mb" -gt 0 ]; then echo ", $swap_used_mb MB in swap"; fi)"
# Overcommit mode: with 1 the OOM killer settles up later, after a clean start.
om=$(cat "${OVERCOMMIT_FILE:-/proc/sys/vm/overcommit_memory}" 2>/dev/null || echo '?')
case "$om" in
0) fact " overcommit 0 heuristic — allocations are granted on a guess" ;;
1) fact " overcommit 1 always — every allocation succeeds; the OOM killer is the only limit" ;;
2) fact " overcommit 2 strict — an allocation fails honestly instead of killing later" ;;
esac
fact " install to $OUT_BIN"
detect_libc
detect_prereqs
detect_wsl
detect_filesystem
detect_docker
detect_inotify
detect_toolchain
}
detect_wsl() {
if ! is_wsl; then
return
fi
# systemd is off by default in WSL; enabling it needs a Windows-side restart.
local wc; wc=$(host_file /etc/wsl.conf)
if [ -r "$wc" ] && grep -qE '^\s*systemd\s*=\s*true' "$wc"; then
fact " 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
fact " resolv.conf pinned (generateResolvConf=false)"
else
fact " - 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
fact " wslconfig memory set: $(grep -E '^\s*memory\s*=' "$wcfg" | tr -d ' ')"
else
MANUAL+=("Cap/raise the WSL VM memory — see what is set versus what booted:
make check mem
It prints the edit to make and the command to apply it.")
fi
}
# Filesystem types that deliver no inotify events (9p, drvfs, network, fuse).
# Checks the fs type, not the path.
watch_hostile_fs() {
local dir="$1" fstype
fstype=$(findmnt -no FSTYPE --target "$dir" 2>/dev/null || true)
[ -n "$fstype" ] || fstype=$(stat -f -c %T "$dir" 2>/dev/null || true)
case "$fstype" in
9p|v9fs|drvfs|cifs|smb3|nfs|nfs4|fuse.sshfs|fuseblk) echo "$fstype" ;;
*) echo "" ;;
esac
}
detect_filesystem() {
local root fstype
root=$(cd .. && pwd -P)
fstype=$(watch_hostile_fs "$root")
if [ -n "$fstype" ]; then
echo " ! this directory is on $fstype — file watching will not work"
MANUAL+=("Move this onto the local disk. Nothing watching files sees changes
on a $fstype mount, and everything else is slower:
cp -r \"$root\" ~/ && cd ~/$(basename "$root")")
else
fact " filesystem $root ($(findmnt -no FSTYPE --target "$root" 2>/dev/null || echo local))"
fi
}
# tilt needs glibc >= 2.34 (measured on Amazon Linux 2). Report the version here;
# `verify` catches the actual failure after installing.
detect_libc() {
local v=""
if command -v ldd >/dev/null 2>&1; then
v=$(ldd --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+$' || true)
fi
if [ -z "$v" ]; then
fact " libc unknown (no ldd) — 'verify' is the real test"
return 0
fi
fact " libc glibc $v"
if [ "$(printf '%s\n2.34\n' "$v" | sort -V | head -1)" != "2.34" ]; then
echo " ! older than glibc 2.34, which tilt needs. kubectl, kind, jq and"
echo " ctlptl are static or libc-only and work here; tilt will not start."
echo " Install the core tier, or run tilt from a container."
fi
return 0
}
# What this script itself needs, so `detect` answers "will install work?".
detect_prereqs() {
local missing=""
if command -v curl >/dev/null 2>&1; then fact " download curl"
elif command -v wget >/dev/null 2>&1; then fact " download wget"
else echo " ! no curl and no wget — nothing can be downloaded"; missing+=" curl"
fi
if command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1; then
fact " checksums ok"
else
echo " ! no sha256sum or shasum — downloads could not be verified"
missing+=" coreutils"
fi
if command -v tar >/dev/null 2>&1 && command -v gzip >/dev/null 2>&1; then
fact " archives tar + gzip"
else
echo " ! no tar/gzip — tilt and ctlptl ship as tarballs, so the dev tier"
echo " cannot be unpacked. The core tier is two bare binaries and is fine."
missing+=" tar gzip"
fi
if [ -n "$missing" ]; then
MANUAL+=("Install what this script needs to run at all:
$(pkg_install_cmd "${missing# }")")
fi
return 0
}
detect_docker() {
# Daemon reachability is the real question; the CLI is only how we ask.
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, and the only thing here
that needs root:
$(pkg_install_cmd "$(docker_pkg)")
sudo systemctl enable --now docker
sudo usermod -aG docker \"\$USER\"
then log out and back in, so the new group applies to your shell.")
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`: a zero count would return 1 under set -e.
if [ "$n" -gt 0 ]; then
echo " kind $n node container(s) running — '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)
fact " 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=$($SHA "$file" | awk '{print $1}')
if [ "$got" != "$want" ]; then
echo "checksum mismatch for $name" >&2
echo " expected $want" >&2
echo " got $got" >&2
exit 1
fi
}
# fetch_bin <name> <url> <sha256> <dest-dir> — a bare binary
fetch_bin() {
local name="$1" url="$2" sha="$3" dest="$4"
local tmp="$dest/.$name.tmp"
echo " fetching $name"
download "$(resolve_url "$url")" "$tmp"
verify "$tmp" "$sha" "$name"
mv "$tmp" "$dest/$name"
chmod +x "$dest/$name"
}
# fetch_tgz <name> <url> <sha256> <dest-dir> <path-inside-archive> <strip>
# Archive layouts differ — tilt's is flat (the binary at the root, strip=0),
# others nest it a directory down — so the caller says which.
fetch_tgz() {
local name="$1" url="$2" sha="$3" dest="$4" inner="$5" strip="$6"
local tmp="$dest/.$name.tgz"
echo " fetching $name"
download "$(resolve_url "$url")" "$tmp"
verify "$tmp" "$sha" "$name"
# --no-same-owner: as root, tar would restore the archive's uid/gid.
tar -xzf "$tmp" -C "$dest" --strip-components="$strip" --no-same-owner "$inner"
rm -f "$tmp"
chmod +x "$dest/$name"
}
# The installer runs as root; hand files in a mounted dir back to the mount point's owner.
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
}
# core: talk to a cluster someone else runs. dev: core plus tools that build clusters.
CORE_TOOLS="kubectl jq"
# No helm (nothing uses a chart). ctlptl wires in a local registry; compose is often
# missing from distro docker packages.
DEV_TOOLS="kind tilt ctlptl docker-compose"
# ── what is already on this machine ───────────────────────────────────────
# A tool already on PATH at its pinned version is left where it is.
pin_of() {
case "$1" in
kubectl) echo "$KUBECTL_VERSION" ;;
jq) echo "$JQ_VERSION" ;;
kind) echo "$KIND_VERSION" ;;
tilt) echo "$TILT_VERSION" ;;
ctlptl) echo "$CTLPTL_VERSION" ;;
docker-compose) echo "$COMPOSE_VERSION" ;;
esac
}
# The version string a binary reports (kubectl needs --client).
reported_version() {
local tool="$1" path="$2"
case "$tool" in
kubectl) "$path" version --client 2>/dev/null ;;
jq) "$path" --version 2>/dev/null ;;
*) "$path" version 2>/dev/null ;;
esac
}
# Does the binary at PATH report PIN? Whole-token match, leading v optional.
# Bash regex rather than grep, deliberately.
version_matches() {
local tool="$1" path="$2" pin="$3" out v re
out=$(reported_version "$tool" "$path") || return 1
v="${pin#v}"
v="${v//./\\.}"
re="(^|[^0-9.])v?${v}([^0-9.]|\$)"
[[ $out =~ $re ]]
}
# DEPS_ONLY narrows a fetch to the tools it names; unset means the whole tier.
# Only install() sets it.
want() { [ -z "${DEPS_ONLY:-}" ] || [[ " $DEPS_ONLY " == *" $1 "* ]]; }
# Every tool in the tier with its state, probed once and reported once. What
# still needs fetching is left in TOOLCHAIN_NEED for install() to act on.
TOOLCHAIN_NEED=""
detect_toolchain() {
local tier="${TIER:-dev}" b pin path found
TOOLCHAIN_NEED=""
local n=0
echo
fact "toolchain (pinned, tier '$tier')"
for b in $(tier_tools "$tier"); do
n=$((n + 1))
pin=$(pin_of "$b")
path=$(command -v "$b" 2>/dev/null || true)
# compose is normally a docker CLI plugin, not on PATH: ask docker instead.
if [ "$b" = docker-compose ] && [ -z "$path" ]; then
if found=$(docker compose version --short 2>/dev/null) && [ -n "$found" ]; then
if [ "${found#v}" = "${pin#v}" ]; then
fact "$(printf " %-8s %-9s %s" "$b" "$pin" "docker cli plugin")"
else
printf " ! %-8s wants %s, the docker cli plugin reports '%s'\n" \
"$b" "$pin" "$found"
TOOLCHAIN_NEED+="$b "
fi
continue
fi
fi
if [ -z "$path" ]; then
printf " - %-8s %-9s not found\n" "$b" "$pin"
TOOLCHAIN_NEED+="$b "
elif version_matches "$b" "$path" "$pin"; then
fact "$(printf " %-8s %-9s %s" "$b" "$pin" "$path")"
else
found=$(reported_version "$b" "$path" 2>/dev/null | head -1 || true)
printf " ! %-8s wants %s, %s reports '%s'\n" "$b" "$pin" "$path" "$found"
TOOLCHAIN_NEED+="$b "
fi
done
if [ -z "$TOOLCHAIN_NEED" ]; then
if [ -n "${VERBOSE:-}" ]; then echo " all $n on PATH — nothing to fetch"
else echo "toolchain all $n pinned tools on PATH (tier $tier)"; fi
else
echo "toolchain 'make deps' fetches only: ${TOOLCHAIN_NEED% }"
fi
}
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
if [ -n "${DEPS_ONLY:-}" ]; then
echo "fetching ${DEPS_ONLY% } (source: $DEPS_SOURCE)"
else
echo "fetching '$tier' toolchain (source: $DEPS_SOURCE)"
fi
if want kubectl; then fetch_bin kubectl "$KUBECTL_URL" "$KUBECTL_SHA256" "$dest"; fi
if want jq; then fetch_bin jq "$JQ_URL" "$JQ_SHA256" "$dest"; fi
if [ "$tier" = "dev" ]; then
if want kind; then fetch_bin kind "$KIND_URL" "$KIND_SHA256" "$dest"; fi
if want tilt; then fetch_tgz tilt "$TILT_URL" "$TILT_SHA256" "$dest" tilt 0; fi
if want ctlptl; then fetch_tgz ctlptl "$CTLPTL_URL" "$CTLPTL_SHA256" "$dest" ctlptl 0; fi
if want docker-compose; then
fetch_bin docker-compose "$COMPOSE_URL" "$COMPOSE_SHA256" "$dest"
fi
fi
fix_ownership "$dest"
# kind writes the kubeconfig as root too; hand that back as well.
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 this cannot perform (${#MANUAL[@]}):"
echo
local n=1
for m in "${MANUAL[@]}"; do
echo " $n. $m"
echo
n=$((n + 1))
done
}
# A verified download proves the right file, not that this machine can run it
# (old glibc breaks tilt). Run each one now.
verify_tools() {
local tier="${1:-dev}" b bin out rc broke=0
echo "checking that each one actually runs"
for b in $(tier_tools "$tier"); do
bin="$OUT_BIN/$b"
if [ ! -x "$bin" ]; then
printf ' %-14s not installed\n' "$b"
continue
fi
# Not piped into `head`: under pipefail, SIGPIPE (141) looked like failure.
rc=0
case "$b" in
kubectl) out=$("$bin" version --client 2>&1) || rc=$? ;;
jq) out=$("$bin" --version 2>&1) || rc=$? ;;
*) out=$("$bin" version 2>&1) || rc=$? ;;
esac
out=${out%%$'\n'*}
if [ "$rc" -eq 0 ]; then
printf ' %-14s %s\n' "$b" "$out"
else
printf ' ! %-12s does not run here: %s\n' "$b" "$out"
broke=1
fi
done
if [ "$broke" -eq 1 ]; then
echo
echo " A binary that downloads and verifies but will not start is almost"
echo " always this distro's libc being older than the release needs."
echo " 'detect' prints the glibc version. The core tier (kubectl + jq)"
echo " has no such dependency and will work regardless."
fi
return 0
}
list() {
echo "pinned, linux/amd64 only:"
printf ' %-14s %s\n' kubectl "$KUBECTL_VERSION"
printf ' %-14s %s\n' jq "$JQ_VERSION"
printf ' %-14s %s\n' kind "$KIND_VERSION"
printf ' %-14s %s\n' tilt "$TILT_VERSION"
printf ' %-14s %s\n' ctlptl "$CTLPTL_VERSION"
printf ' %-14s %s\n' docker-compose "$COMPOSE_VERSION"
echo
echo " core = $CORE_TOOLS"
echo " dev = $CORE_TOOLS $DEV_TOOLS"
echo
echo "Checksums are pinned in the block at the top of this file. To bump one,"
echo "take the new checksum from the publisher's own release list — the header"
echo "comment has the exact commands."
return 0
}
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
# The same version in both places is not a conflict: nothing changes for
# any other project whichever copy PATH happens to find first.
if version_matches "$b" "$existing" "$(pin_of "$b")"; then continue; fi
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")
}
# Link the fetched docker-compose into ~/.docker/cli-plugins so `docker compose` works.
install_compose_plugin() {
local src="$OUT_BIN/docker-compose" dir="$HOME/.docker/cli-plugins"
[ -x "$src" ] || return 0
mkdir -p "$dir"
# A real file there belongs to something else (docker-desktop, distro): don't overwrite.
if [ -e "$dir/docker-compose" ] && [ ! -L "$dir/docker-compose" ]; then
MANUAL+=("Something already installs the compose plugin at
$dir/docker-compose
To use rig's pinned build instead:
ln -sf $src $dir/docker-compose")
return 0
fi
ln -sfn "$src" "$dir/docker-compose"
echo " compose plugin -> $dir/docker-compose"
return 0
}
install() {
local tier="${1:-dev}" b
TIER="$tier"
detect
# detect_toolchain has already probed PATH. Fetch only what it found missing
# or at the wrong version; a tool already present at its pin stays where it is.
if [ -n "$TOOLCHAIN_NEED" ]; then
echo
DEPS_ONLY="$TOOLCHAIN_NEED" fetch "$tier"
echo
echo "installed to $OUT_BIN ($tier):"
for b in $TOOLCHAIN_NEED; do
if [ -x "$OUT_BIN/$b" ]; then echo " $b"; fi
done
if [ "$tier" = "core" ]; then
echo " (no kind/tilt — 'make deps dev' adds them)"
fi
# Only when compose was fetched, never at a copy rig did not install.
case " $TOOLCHAIN_NEED " in
*" docker-compose "*) install_compose_plugin ;;
esac
# PATH advice only when something actually landed in OUT_BIN.
case ":${PATH}:" in
*":$OUT_BIN:"*) ;;
*) MANUAL+=("Put the toolchain on your PATH — add to ~/.bashrc:
export PATH=\"${OUT_BIN}:\$PATH\"") ;;
esac
fi
warn_shadowing "$tier"
report_manual
}
# ── main ───────────────────────────────────────────────────────────────────
require_linux
# Shift only if there is an argument: a bare `shift` returns 1 under set -e.
cmd="${1:-install}"
[ $# -gt 0 ] && shift
# ── manifests rig's own addons apply ───────────────────────────────────────
# Pinned (URL + SHA256), fetched through the same DEPS_SOURCE resolver as the
# binaries and verified, then applied from disk: an offline machine needs no
# network for them. Default home: vendor/manifests/ in rig's folder (gitignored).
MANIFESTS_HOME="${MANIFESTS_HOME:-$(cd .. && pwd)/vendor/manifests}"
BAKED_MANIFESTS="${BAKED_MANIFESTS:-/opt/rig/manifests}"
MANIFEST_NAMES="METALLB CERT_MANAGER METRICS_SERVER"
manifest_path() { # NAME dir
local v="${1}_VERSION"
echo "$2/$(echo "$1" | tr 'A-Z_' 'a-z-')-${!v}.yaml"
}
# Make one pinned manifest present and verified in dir; print only its path.
fetch_manifest() { # NAME dir
local name="$1" dir="$2" url_var="${1}_MANIFEST_URL" sha_var="${1}_MANIFEST_SHA256" file
if [ -z "${!url_var:-}" ] || [ -z "${!sha_var:-}" ]; then
echo "no pinned manifest for $name (${url_var} / ${sha_var} unset)" >&2
exit 1
fi
file=$(manifest_path "$name" "$dir")
if [ -f "$file" ] && [ "$($SHA "$file" | awk '{print $1}')" = "${!sha_var}" ]; then
echo "$file"
return
fi
mkdir -p "$dir"
if [ "$DEPS_SOURCE" = baked ]; then
cp "$(manifest_path "$name" "$BAKED_MANIFESTS")" "$file.tmp"
else
download "$(resolve_url "${!url_var}")" "$file.tmp"
fi
verify "$file.tmp" "${!sha_var}" "$name manifest"
mv "$file.tmp" "$file"
echo "$file"
}
fetch_manifests() { # [--to DIR]
local dest="$MANIFESTS_HOME" n
if [ "${1:-}" = --to ]; then dest="$(abspath "${2:?--to needs a directory}")"; fi
echo "fetching the addons' manifests into $dest (source: $DEPS_SOURCE)"
for n in $MANIFEST_NAMES; do
echo " $n $(fetch_manifest "$n" "$dest")"
done
}
# ── snapshot: this machine as a host fixture ───────────────────────────────
# Writes what detect reads, cut down to what it needs — never the environment, the
# home directory or the host name — plus the lines detect prints for it. A fact of
# one machine: keep it with an overlay or in rig's local/, never in rig itself, and
# replay it with ctrl/hosttest.sh. Notes: docs/notes/installer-testing.md
snapshot() {
local dest r f
dest="$(abspath "${1:-host-snapshot}")"
if [ -n "$(ls -A "$dest" 2>/dev/null)" ]; then
echo "snapshot: $dest already holds something — pick an empty directory" >&2
exit 1
fi
r="$dest/root"
mkdir -p "$r/etc" "$r/proc/sys/vm"
f=$(host_file /etc/os-release)
if [ -r "$f" ]; then grep -E '^(PRETTY_NAME|NAME|VERSION_ID|ID|ID_LIKE)=' "$f" > "$r/etc/os-release"; fi
# The kernel release says WSL or not; the full build string names build hosts.
echo "Linux version $(awk '{print $3; exit}' "$(host_file /proc/version)" 2>/dev/null || uname -r)" > "$r/proc/version"
grep -E '^(MemTotal|MemAvailable|SwapTotal|SwapFree):' "${MEMINFO:-/proc/meminfo}" > "$r/proc/meminfo"
cat "${OVERCOMMIT_FILE:-/proc/sys/vm/overcommit_memory}" > "$r/proc/sys/vm/overcommit_memory" 2>/dev/null || true
f=$(host_file /etc/wsl.conf)
if [ -r "$f" ]; then
# Section headers and the two keys detect reads; a [user] default= names a person.
grep -E '^[[:space:]]*(\[[a-z0-9]+\]|systemd[[:space:]]*=|generateResolvConf[[:space:]]*=)' "$f" > "$r/etc/wsl.conf" || true
fi
f=$(ls "$HOST_ROOT"/mnt/c/Users/*/.wslconfig 2>/dev/null | head -1 || true)
if [ -n "$f" ] && grep -qE '^\s*memory\s*=' "$f"; then
mkdir -p "$r/mnt/c/Users/user"
{ echo "[wsl2]"; grep -E '^\s*memory\s*=' "$f"; } > "$r/mnt/c/Users/user/.wslconfig"
fi
{
echo "# for the record; not replayed"
echo "arch=$(arch)"
echo "glibc=$(ldd --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+$' || echo unknown)"
echo "taken=$(date -u +%Y-%m-%d)"
} > "$dest/facts.txt"
# The lines the fixture itself decides, as detect prints them for it now.
{
echo "# Written by deps.sh snapshot: what detect said about this machine."
env -u MEMINFO -u OVERCOMMIT_FILE HOST_ROOT="$r" bash "./$(basename "${BASH_SOURCE[0]}")" detect all 2>/dev/null \
| grep -E '^ (distro|memory|overcommit|systemd|resolv\.conf|wslconfig) |^ ! systemd|^ - resolv\.conf' \
| sed 's/^ /+ /'
} > "$dest/expect.txt"
echo "wrote $dest"
(cd "$dest" && find . -type f | sort | sed 's|^\./| |')
echo "keep it with an overlay or in rig's local/, never in rig; replay it with rig's hosttest.sh"
}
# Baked mode copies binaries already in the image, so it needs no downloader.
need_downloads() {
require_amd64
if [ "$DEPS_SOURCE" != baked ]; then pick_downloader; fi
pick_sha
}
case "$cmd" in
detect) if [ "${1:-}" = all ]; then VERBOSE=1; fi; detect; report_manual ;;
list) list ;;
verify) verify_tools "${1:-dev}" ;;
fetch) need_downloads; fetch "$@" ;;
install) need_downloads; install "${1:-dev}" ;;
manifest) need_downloads
fetch_manifest "${1:?usage: $0 manifest <METALLB|CERT_MANAGER|METRICS_SERVER>}" "$MANIFESTS_HOME" ;;
manifests) need_downloads; fetch_manifests "$@" ;;
snapshot) snapshot "${1:-}" ;;
*) echo "usage: $0 [detect [all]|list|verify|fetch|install|manifest NAME|manifests]" >&2
echo " install [core|dev] (default dev)" >&2
echo " fetch [core|dev] [--to DIR]" >&2
echo " manifests [--to DIR] the addons' pinned manifests, verified" >&2
echo " snapshot [DIR] this machine as a host fixture (no secrets)" >&2
echo " OUT_BIN=<dir> overrides the install directory" >&2
exit 1 ;;
esac

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

@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Documentation: render the diagrams, and serve the pages from a throwaway nginx container.
# Usage: docs.sh serve | graphs
# Notes: docs/notes/docs.md
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

View File

@@ -0,0 +1,21 @@
# EXAMPLE PROFILE (optional): copy to mirror.env, then PROFILE=mirror; overlays the defaults.
# mirror — images via a pull-through cache of an internal registry, TLS and metrics addons.
# A profile says how this machine reaches the world; what runs is an overlay's business.
# Notes: docs/notes/env.md
PROFILE_NAME=mirror
K8S_VERSION=v1_36
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).
# Real ports only if this is the ONLY environment and nothing owns :80; `make check` tests it.
# HTTP_PORT=80
# HTTPS_PORT=443
# Set these in ctrl/.env (gitignored), not here:
# REGISTRY_REMOTE_URL=https://registry.internal.example/api/docker/docker-virtual
# REGISTRY_USER / REGISTRY_PASSWORD
# REGISTRY_CA_FILE=/path/to/internal-root-ca.crt

View File

@@ -0,0 +1,15 @@
# EXAMPLE PROFILE (optional): copy to offline.env, then PROFILE=offline; overlays the defaults.
# offline — air-gapped: images from a preloaded local registry; pair with DEPS_SOURCE=baked.
# Notes: docs/notes/env.md
PROFILE_NAME=offline
K8S_VERSION=v1_36
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

52
rig/ctrl/hosttest.sh Executable file
View File

@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Replay host fixtures: run `deps.sh detect all` against a stand-in machine and check
# what it must (and must not) say. No docker, no network, no root.
# Usage: hosttest.sh [FIXTURE_DIR...] default: tests/hosts/* exits 1 on a mismatch
# A fixture is root/ (the files detect reads), expect.txt (+ must appear, - must not,
# exit N), and an optional env (KEY=value lines). `deps.sh snapshot` writes one.
# Notes: docs/notes/installer-testing.md
set -uo pipefail
cd "$(dirname "$0")"
dirs=("$@")
if [ ${#dirs[@]} -eq 0 ]; then dirs=(../tests/hosts/*/); fi
rc=0 passed=0 failed=0
for d in "${dirs[@]}"; do
d="${d%/}"
if [ ! -f "$d/expect.txt" ]; then
echo " FAIL $d: no expect.txt — not a host fixture" >&2
rc=1; failed=$((failed + 1)); continue
fi
# Only the fixture's own settings: the caller's HOST_ROOT, MEMINFO or UNAME_S
# must not leak into a replay.
run=(env -u HOST_ROOT -u MEMINFO -u OVERCOMMIT_FILE -u UNAME_S)
if [ -d "$d/root" ]; then run+=(HOST_ROOT="$(cd "$d/root" && pwd)"); fi
if [ -f "$d/env" ]; then
while IFS= read -r kv; do run+=("$kv"); done < <(grep -vE '^[[:space:]]*(#|$)' "$d/env")
fi
out=$("${run[@]}" bash ./deps.sh detect all 2>&1)
code=$?
bad=""
want_exit=0
while IFS= read -r line; do
case "$line" in
'+ '*) grep -qF -- "${line#+ }" <<< "$out" || bad+=$'\n'" missing: ${line#+ }" ;;
'- '*) grep -qF -- "${line#- }" <<< "$out" && bad+=$'\n'" present: ${line#- }" ;;
'exit '*) want_exit="${line#exit }" ;;
esac
done < <(grep -vE '^[[:space:]]*(#|$)' "$d/expect.txt")
if [ "$code" != "$want_exit" ]; then bad+=$'\n'" exit: $code, wanted $want_exit"; fi
if [ -z "$bad" ]; then
printf ' ok %s\n' "$(basename "$d")"
passed=$((passed + 1))
else
printf ' FAIL %s%s\n' "$(basename "$d")" "$bad"
rc=1; failed=$((failed + 1))
fi
done
printf '%d host fixture(s) as expected, %d not\n' "$passed" "$failed"
exit "$rc"

101
rig/ctrl/installtest.sh Executable file
View File

@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# The installer on clean machines: the generated kit (standalone/default/rigdeps.sh) in
# stock distro containers, as a non-root user, the way it reaches a real machine.
# Needs docker and the network; takes minutes. Exits 1 on a failure.
# Usage: installtest.sh [IMAGE...] default: ubuntu:22.04 debian:trixie-slim, then offline
# Notes: docs/notes/installer-testing.md
set -uo pipefail
cd "$(dirname "$0")"
KIT="$(cd .. && pwd)/standalone/default/rigdeps.sh"
images=("$@")
if [ ${#images[@]} -eq 0 ]; then images=(ubuntu:22.04 debian:trixie-slim); fi
rc=0
passed=0
check() { # name, expected, actual
if [ "$2" = "$3" ]; then
printf ' ok %s\n' "$1"
passed=$((passed + 1))
else
printf ' FAIL %s\n expected: %s\n got: %s\n' "$1" "$2" "$3"
rc=1
fi
}
if ! docker info >/dev/null 2>&1; then
echo "installtest needs a running docker it can reach" >&2
exit 1
fi
# A stale kit would test yesterday's installer.
if ! bash ./standalone.sh check >/dev/null 2>&1; then
echo "the kit is stale — run: make standalone" >&2
exit 1
fi
for img in "${images[@]}"; do
printf '\n%s\n' "$img"
# A stock image has no curl or wget: the installer must say so and stop, not
# half-install. This is the bootstrap paradox BOOTSTRAP.md describes.
out=$(docker run --rm -v "$KIT:/kit/rigdeps.sh:ro" "$img" bash /kit/rigdeps.sh install dev 2>&1)
code=$?
check "bare: install refuses" "1" "$code"
check "bare: and names what is missing" "yes" \
"$(grep -q 'neither curl nor wget' <<< "$out" && echo yes || echo no)"
# The one root step a machine owner takes, then everything else as a plain user —
# the Workspace's case: no sudo from the installer, tools in ~/.local/bin.
out=$(docker run --rm -v "$KIT:/kit/rigdeps.sh:ro" "$img" bash -c '
set -e
apt-get update -qq >/dev/null
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq curl ca-certificates >/dev/null
useradd -m t
su t -s /bin/bash -c "
set -e
bash /kit/rigdeps.sh install dev
echo ==verify; PATH=\$HOME/.local/bin:\$PATH bash /kit/rigdeps.sh verify dev
echo ==manifests; bash /kit/rigdeps.sh manifests --to \$HOME/m
echo ==bin; ls \$HOME/.local/bin
echo ==m; ls \$HOME/m
"' 2>&1)
code=$?
check "user: install, verify and manifests succeed" "0" "$code"
check "user: the dev tier lands in ~/.local/bin" "ctlptl docker-compose jq kind kubectl tilt" \
"$(sed -n '/^==bin$/,/^==m$/p' <<< "$out" | grep -vE '^==' | sort | xargs)"
check "user: tells them to put it on PATH" "yes" \
"$(grep -q 'Put the toolchain on your PATH' <<< "$out" && echo yes || echo no)"
check "user: the three manifests, verified" "3" \
"$(sed -n '/^==m$/,$p' <<< "$out" | grep -c '\.yaml$')"
if [ "$code" -ne 0 ]; then printf '%s\n' "$out" | tail -15 | sed 's/^/ | /'; fi
done
# The air-gapped path: everything baked into the image, then run with no network at all.
printf '\noffline (deps-full, --network none)\n'
tag="rig-installtest:full"
if docker build -q -f Dockerfile.deps --target deps-full -t "$tag" .. >/dev/null 2>&1; then
out=$(docker run --rm --network none --entrypoint bash "$tag" -c '
set -e
/work/rigdeps.sh install dev
/work/rigdeps.sh manifests --to /tmp/m
echo ==bin; ls /out/bin
echo ==m; ls /tmp/m' 2>&1)
code=$?
check "offline: install and manifests succeed" "0" "$code"
check "offline: the dev tier, from the image" "ctlptl docker-compose jq kind kubectl tilt" \
"$(sed -n '/^==bin$/,/^==m$/p' <<< "$out" | grep -vE '^==' | sort | xargs)"
check "offline: the three manifests, from the image" "3" \
"$(sed -n '/^==m$/,$p' <<< "$out" | grep -c '\.yaml$')"
if [ "$code" -ne 0 ]; then printf '%s\n' "$out" | tail -15 | sed 's/^/ | /'; fi
docker rmi -f "$tag" >/dev/null 2>&1 || true
else
check "offline: the deps-full image builds" "yes" "no"
fi
printf '\n'
if [ "$rc" -eq 0 ]; then
printf '%d install checks passed\n' "$passed"
else
printf 'FAILED — the installer did not do on a clean machine what it says\n' >&2
fi
exit "$rc"

View File

@@ -0,0 +1,23 @@
# The cluster. Add nodes or port mappings here, then `make cluster reset`.
# ctrl/cluster.sh substitutes (sed): CLUSTER, NODE_IMAGE, HTTP_PORT, HOST_WORKDIR, OVERLAY_DIR
# lib/config.sh reads the node count back from this file.
# Notes: docs/notes/kind-config.md
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: ${CLUSTER}
# containerd reads per-host registry config from certs.d (written by registry.sh).
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, owned by an in-cluster gateway (no ingress controller).
extraPortMappings:
- containerPort: 30080
hostPort: ${HTTP_PORT}
listenAddress: "0.0.0.0"
protocol: TCP

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

@@ -0,0 +1,305 @@
# Shared config loading: how the config layers compose. Sourced, never executed.
# Precedence, weakest first:
# defaults < versions.env < env.d/<profile> < <overlay>/rig.env < .env < caller's env.
# Run from ctrl/.
# Notes: docs/notes/config.md
# Per-invocation overrides: restored after the files are read, so the caller wins.
# NODES is deliberately not here (read from the kind config).
CONFIG_OVERRIDABLE="PROFILE OVERLAY CLUSTER K8S_VERSION KIND_CONFIG ADDONS
REGISTRY_MODE INGRESS_MODE DNS_MODE TILT_PORT
SOURCE ARCH DEPS_SOURCE HTTP_PORT HTTPS_PORT
REGISTRY_PORT MANIFESTS_DIR"
# rig's own example, used when no overlay is named (relative to rig's root).
DEFAULT_OVERLAY=examples/starter
# A rig-root-relative path as seen from ctrl/; absolute paths pass through.
_from_ctrl() { case "$1" in /*) echo "$1" ;; *) echo "../$1" ;; esac; }
# The same, absolute. Empty if it does not exist.
_abs_from_ctrl() { (cd "$(_from_ctrl "$1")" 2>/dev/null && pwd); }
# The environment's folder — the overlay's when one is named, else rig's —
# reduced to a DNS label kind accepts as a cluster name.
default_cluster_name() {
local n
if [ -n "${OVERLAY:-}" ]; then
n=$(basename "$(_abs_from_ctrl "$OVERLAY_DIR")")
else
n=$(basename "$(cd .. && pwd)")
fi
n=$(echo "$n" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-')
n=$(echo "$n" | sed 's/^-*//; s/-*$//')
echo "${n:-rig}"
}
# Base of this environment's 10-port block; cksum so it is the same on every machine.
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
# RIG_PORTABLE skips the machine-local .env (set by config_snapshot for kits).
if [ -z "${RIG_PORTABLE:-}" ] && [ -f ./.env ]; then source ./.env; fi
set +a
# Re-apply overrides now so PROFILE is the caller's before we pick the file.
_config_restore "$saved"
# A profile is optional; naming one that does not exist is an error.
local profile="${PROFILE:-}" layered=""
if [ -n "$profile" ] && [ "$profile" != default ]; then
if [ ! -f "./env.d/${profile}.env" ]; then
echo "no such profile: env.d/${profile}.env" >&2
if [ -d "../examples/${profile}" ]; then
echo " it is an example overlay now: OVERLAY=examples/${profile}" >&2
fi
echo "available: $(config_profiles | tr '\n' ' ')" >&2
exit 1
fi
set -a
source "./env.d/${profile}.env"
set +a
layered=1
fi
# The overlay: one folder, outside rig, holding a use case (docs/notes/overlay.md).
# Named ones must exist; with none named, rig's own example is used if present.
OVERLAY_DIR=""
if [ -n "${OVERLAY:-}" ]; then
OVERLAY_DIR="${OVERLAY%/}"
if [ ! -d "$(_from_ctrl "$OVERLAY_DIR")" ]; then
echo "no overlay at OVERLAY=${OVERLAY} (relative to rig's folder, or absolute)" >&2
exit 1
fi
elif [ -d "../${DEFAULT_OVERLAY}" ]; then
OVERLAY_DIR="$DEFAULT_OVERLAY"
fi
# Its rig.env may not choose the profile or the overlay (both are chosen before
# it loads), and the paths it sets are relative to the overlay.
local ov_env="" m_before k_before
if [ -n "$OVERLAY_DIR" ]; then ov_env="$(_from_ctrl "$OVERLAY_DIR")/rig.env"; fi
if [ -n "$ov_env" ] && [ -f "$ov_env" ]; then
if grep -qE '^[[:space:]]*(export[[:space:]]+)?(PROFILE|OVERLAY)=' "$ov_env"; then
echo "$ov_env: an overlay's rig.env cannot set PROFILE or OVERLAY (they choose it)" >&2
exit 1
fi
m_before="${MANIFESTS_DIR-}" k_before="${KIND_CONFIG-}"
set -a
source "$ov_env"
set +a
if [ "${MANIFESTS_DIR-}" != "$m_before" ]; then
case "$MANIFESTS_DIR" in /*|none|"") ;; *) MANIFESTS_DIR="${OVERLAY_DIR}/${MANIFESTS_DIR}" ;; esac
fi
if [ "${KIND_CONFIG-}" != "$k_before" ]; then
case "$KIND_CONFIG" in /*|"") ;; *) KIND_CONFIG="$(dirname "$ov_env")/${KIND_CONFIG}" ;; esac
fi
layered=1
fi
# The machine and the caller still win over both.
if [ -n "$layered" ]; then
set -a
if [ -z "${RIG_PORTABLE:-}" ] && [ -f ./.env ]; then source ./.env; fi
set +a
_config_restore "$saved"
fi
# The defaults a profile would otherwise have to supply. Weakest of all: a
# profile, ctrl/.env and the caller each override them.
PROFILE_NAME="${PROFILE_NAME:-default}"
ADDONS="${ADDONS-}"
# local, not none: with no registry an unqualified image name means
# docker.io/library/<name>, and a default must not make that disclosure.
REGISTRY_MODE="${REGISTRY_MODE:-local}"
INGRESS_MODE="${INGRESS_MODE:-hostport}"
DNS_MODE="${DNS_MODE:-hosts}"
# The newest node image versions.env pins, found rather than restated, so
# bumping the pins moves the default with them.
if [ -z "${K8S_VERSION:-}" ]; then
K8S_VERSION=$(compgen -v NODE_IMAGE_v | sort -V | tail -1)
K8S_VERSION="${K8S_VERSION#NODE_IMAGE_}"
fi
# Identity follows the folder, so a renamed copy is a distinct environment.
CLUSTER="${CLUSTER:-$(default_cluster_name)}"
KUBECONTEXT="kind-${CLUSTER}"
# Host ports: fill only the gaps from the derived block; anything already set wins.
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))}"
# Where the workload's manifests live, relative to rig's folder (or absolute):
# the overlay's k8s/overlays/dev unless something names another. `none`: rig
# applies none (the overlay's Tiltfile does). A named folder must exist.
if [ "${MANIFESTS_DIR:-}" = ctrl/k8s/overlays/dev ] && [ ! -d ../ctrl/k8s/overlays/dev ]; then
# The old default, pinned by an older .env.example; rig's examples moved.
STALE_MANIFESTS_DIR="$MANIFESTS_DIR"
MANIFESTS_DIR=""
fi
if [ -z "${MANIFESTS_DIR:-}" ] && [ -n "$OVERLAY_DIR" ] \
&& [ -d "$(_from_ctrl "$OVERLAY_DIR")/k8s/overlays/dev" ]; then
MANIFESTS_DIR="$OVERLAY_DIR/k8s/overlays/dev"
fi
MANIFESTS_DIR="${MANIFESTS_DIR:-}"
if [ "$MANIFESTS_DIR" = none ]; then
MANIFESTS_DIR=""
elif [ -n "$MANIFESTS_DIR" ] && [ ! -d "$(_from_ctrl "$MANIFESTS_DIR")" ]; then
echo "no manifests at MANIFESTS_DIR=${MANIFESTS_DIR} (relative to rig's folder, or absolute)" >&2
exit 1
fi
# Profiles name a k8s minor (v1_36); versions.env holds the pinned digest.
local var="NODE_IMAGE_${K8S_VERSION}"
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 is one file: the overlay's kind-config.yaml.tpl if it has one,
# else rig's k8s/kind-config.yaml.tpl. KIND_CONFIG is "use this file instead",
# for a project that builds its own cluster through rig (relative to ctrl/, or absolute).
if [ -z "${KIND_CONFIG:-}" ] && [ -n "$OVERLAY_DIR" ] \
&& [ -f "$(_from_ctrl "$OVERLAY_DIR")/kind-config.yaml.tpl" ]; then
KIND_CONFIG="$(_from_ctrl "$OVERLAY_DIR")/kind-config.yaml.tpl"
fi
KIND_CONFIG="${KIND_CONFIG:-./k8s/kind-config.yaml.tpl}"
if [ ! -f "$KIND_CONFIG" ]; then
echo "no kind config at KIND_CONFIG=${KIND_CONFIG}" >&2
exit 1
fi
# Read the node count back out of the file rather than restating it:
# check.sh and the memory tool size their budget on NODES.
NODES=$(grep -c '^ - role:' "$KIND_CONFIG")
# Measured MB per node (cluster alone, errs high for workers); shared by
# check.sh, the memory tool and standalone kits.
NODE_MB=800
}
# Render the kind config to stdout with sed (not envsubst) over an explicit variable list.
# HOST_WORKDIR and OVERLAY_DIR must be host paths: the host dockerd resolves hostPath entries.
render_kind_config() {
local host_workdir="${HOST_WORKDIR:-$(cd .. && pwd)}" overlay_dir=""
if [ -n "$OVERLAY_DIR" ]; then overlay_dir=$(_abs_from_ctrl "$OVERLAY_DIR"); fi
sed -e "s|\${CLUSTER}|${CLUSTER}|g" \
-e "s|\${NODE_IMAGE}|${NODE_IMAGE}|g" \
-e "s|\${HTTP_PORT}|${HTTP_PORT}|g" \
-e "s|\${HOST_WORKDIR}|${host_workdir}|g" \
-e "s|\${OVERLAY_DIR}|${overlay_dir}|g" \
"$KIND_CONFIG"
}
_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
}
# ── what a standalone kit needs to know ────────────────────────────────────
# The questions ctrl/standalone.sh asks, so it never knows how config is stored.
# Every configuration rig can run as, one per line: each profile, or `default`
# when there are none. Never empty.
config_profiles() {
local f found=""
for f in ./env.d/*.env; do
[ -e "$f" ] || continue
f=${f##*/}; echo "${f%.env}"; found=1
done
[ -n "$found" ] || echo default
}
# What load_config sets, minus the machine-local layer, as `declare -p` lines.
# Usage: config_snapshot <profile> | --current (found by difference, not a list)
config_snapshot() {
local _rig_snap_choices
if [ "$1" = --current ]; then
_rig_snap_choices=$( (
load_config >/dev/null || exit 1
for _rig_snap_n in $CONFIG_OVERRIDABLE; do
if [ -n "${!_rig_snap_n+x}" ]; then printf 'export %s=%q\n' "$_rig_snap_n" "${!_rig_snap_n}"; fi
done
) ) || return 1
else
_rig_snap_choices="export PROFILE=$(printf '%q' "$1")"
fi
(
# Nothing from the caller's shell may leak into a kit.
for _rig_snap_n in $CONFIG_OVERRIDABLE; do unset "$_rig_snap_n"; done
declare -A _rig_snap_was=()
for _rig_snap_n in $(compgen -v); do
_rig_snap_was[$_rig_snap_n]="${!_rig_snap_n-}"
done
eval "$_rig_snap_choices"
RIG_PORTABLE=1 load_config >/dev/null
for _rig_snap_n in $(compgen -v); do
case "$_rig_snap_n" in
_rig_snap_*|RIG_PORTABLE|BASH*|FUNCNAME|PIPESTATUS|LINENO|RANDOM|SRANDOM|\
SECONDS|EPOCH*|HISTCMD|COLUMNS|LINES|PWD|OLDPWD|_|SHLVL|OPTIND|OPTERR) continue ;;
esac
if [ -z "${_rig_snap_was[$_rig_snap_n]+x}" ] \
|| [ "${_rig_snap_was[$_rig_snap_n]}" != "${!_rig_snap_n-}" ]; then
declare -p "$_rig_snap_n"
fi
done
)
}
# The profile this machine runs, as load_config resolves it here.
config_current_profile() { ( load_config >/dev/null && echo "$PROFILE_NAME" ); }
# Names (never values) of .env keys an export does not carry, e.g. credentials.
config_left_out() {
[ -f ./.env ] || return 0
local k
for k in $(sed -nE 's/^[[:space:]]*(export[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*)=.*/\2/p' ./.env | sort -u); do
case " $(echo $CONFIG_OVERRIDABLE) " in
*" $k "*) ;;
*) echo "$k" ;;
esac
done
}
# Print a load_config with a resolution frozen in, for a standalone kit to carry.
# The caller's env still wins; derived values (e.g. ports) stay fixed.
config_freeze() {
local snap
snap=$(config_snapshot "$1") || return 1
cat <<'EOF'
load_config() {
local k saved=""
for k in $CONFIG_OVERRIDABLE; do
if [ -n "${!k+x}" ]; then saved+="$k=$(printf '%q' "${!k}")"$'\n'; fi
done
EOF
printf '%s\n' "$snap" | sed -E 's/^declare --* / declare -g /; s/^declare -([a-zA-Z]+) / declare -g\1 /'
cat <<'EOF'
_config_restore "$saved"
}
EOF
}

684
rig/ctrl/mem.sh Executable file
View File

@@ -0,0 +1,684 @@
#!/usr/bin/env bash
# rig:standalone rigmini status
# rig's memory tool (also generated as rigmini.sh): what the machine advertises vs. what it survives.
# Usage: mem.sh status | push [--to GB] [--to-oom] | all [--budget GB] | backup | restore (WSL)
# Notes: docs/notes/mem.md
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
# ── defaults ───────────────────────────────────────────────────────────────
STEP_MB=0 # per allocation; 0 means scale it to the ceiling. See push().
STEP_EXPLICIT=no # whether --step was given, which turns the scaling off.
TO_MB="" # --to: stop here regardless. Empty means no hard cap.
TO_OOM=no # --to-oom: opt in to running until the kernel intervenes.
BUDGET_GB="" # --budget; empty means what this profile's cluster needs, from rig.
BUDGET_EXPLICIT=no # whether --budget was given, which retires the guess below.
# ── platform ───────────────────────────────────────────────────────────────
# Refuse Git Bash / MSYS / Cygwin and kernels without /proc, with a clear message.
require_linux() {
case "${UNAME_S:-$(uname -s)}" in
MINGW*|MSYS*|CYGWIN*)
cat >&2 <<'EOF'
This has to run inside WSL, not Git Bash / MSYS / Cygwin.
If WSL is not installed yet, from an elevated PowerShell or Command Prompt:
wsl --install
That enables Windows features and needs a reboot, so it is not something this
script will do for you. Afterwards, open the Linux shell it installs and run
this from there.
EOF
exit 1 ;;
esac
# Everything below reads /proc. Without it there is nothing to measure, and
# failing here beats printing a page of empty fields.
if [ ! -r /proc/meminfo ]; then
echo "no readable /proc/meminfo — this needs a Linux kernel." >&2
echo "On macOS or a BSD none of the numbers below exist." >&2
exit 1
fi
}
is_wsl() { grep -qi microsoft /proc/version 2>/dev/null; }
is_container() {
[ -f /.dockerenv ] && return 0
grep -qE '(docker|containerd|kubepods|lxc|podman)' /proc/1/cgroup 2>/dev/null
}
platform() {
if is_wsl; then echo WSL
elif is_container; then echo container
else echo "native linux"
fi
}
# ── reading memory ─────────────────────────────────────────────────────────
mb() { echo $(( $(awk "/^$1:/{print \$2}" /proc/meminfo) / 1024 )); }
# MemAvailable arrived in kernel 3.14. Older kernels — and they turn up on
# corporate images — need the estimate it replaced, which is worse but not wrong.
avail_meminfo_mb() {
if grep -q '^MemAvailable:' /proc/meminfo; then
mb MemAvailable
else
awk '/^(MemFree|Buffers|Cached):/{t+=$2} END{print int(t/1024)}' /proc/meminfo
fi
}
# This cgroup's limit/usage files, set once by find_cgroup (cheap for the poll loop).
CG_MAX_FILE=""
CG_CUR_FILE=""
CG_VERSION=""
find_cgroup() {
local rel
# Top of tree first (right inside a container), then this shell's own slice
# from /proc/self/cgroup (right on a host).
if [ -r /sys/fs/cgroup/memory.max ]; then
CG_VERSION=v2
CG_MAX_FILE=/sys/fs/cgroup/memory.max
CG_CUR_FILE=/sys/fs/cgroup/memory.current
elif [ -r /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then
CG_VERSION=v1
CG_MAX_FILE=/sys/fs/cgroup/memory/memory.limit_in_bytes
CG_CUR_FILE=/sys/fs/cgroup/memory/memory.usage_in_bytes
fi
rel=$(awk -F: '$1=="0"{print $3; exit}' /proc/self/cgroup 2>/dev/null || true)
if [ -n "$rel" ] && [ "$rel" != "/" ] && [ -r "/sys/fs/cgroup${rel}/memory.max" ]; then
CG_VERSION=v2
CG_MAX_FILE="/sys/fs/cgroup${rel}/memory.max"
CG_CUR_FILE="/sys/fs/cgroup${rel}/memory.current"
return 0
fi
rel=$(awk -F: '$2 ~ /(^|,)memory(,|$)/{print $3; exit}' /proc/self/cgroup 2>/dev/null || true)
if [ -n "$rel" ] && [ "$rel" != "/" ] \
&& [ -r "/sys/fs/cgroup/memory${rel}/memory.limit_in_bytes" ]; then
CG_VERSION=v1
CG_MAX_FILE="/sys/fs/cgroup/memory${rel}/memory.limit_in_bytes"
CG_CUR_FILE="/sys/fs/cgroup/memory${rel}/memory.usage_in_bytes"
fi
return 0
}
# The cap in MB, or "" when unlimited ("max", or any value >= MemTotal).
cgroup_cap_mb() {
local raw cap
[ -n "$CG_MAX_FILE" ] && [ -r "$CG_MAX_FILE" ] || { echo ""; return 0; }
raw=$(cat "$CG_MAX_FILE" 2>/dev/null || echo max)
[ "$raw" = "max" ] && { echo ""; return 0; }
case "$raw" in ''|*[!0-9]*) echo ""; return 0 ;; esac
cap=$((raw / 1024 / 1024))
[ "$cap" -ge "$(mb MemTotal)" ] && { echo ""; return 0; }
echo "$cap"
}
cgroup_used_mb() {
local raw
[ -n "$CG_CUR_FILE" ] && [ -r "$CG_CUR_FILE" ] || { echo ""; return 0; }
raw=$(cat "$CG_CUR_FILE" 2>/dev/null || echo "")
case "$raw" in ''|*[!0-9]*) echo ""; return 0 ;; esac
echo $((raw / 1024 / 1024))
}
# ulimit -v is a per-process address-space cap. It stops YOU long before the box
# does, and because it is inherited from a login shell it is easy to hit without
# knowing it is set.
ulimit_v_mb() {
local v; v=$(ulimit -v 2>/dev/null || echo unlimited)
[ "$v" = "unlimited" ] && { echo ""; return 0; }
case "$v" in ''|*[!0-9]*) echo ""; return 0 ;; esac
echo $((v / 1024))
}
# The number everything else is about: the lowest of the things that can stop
# you. Printed at the end of `status` and used as the sanity bound in `push`.
effective_ceiling_mb() {
local c; c=$(mb MemTotal)
local cap; cap=$(cgroup_cap_mb)
local ul; ul=$(ulimit_v_mb)
[ -n "$cap" ] && [ "$cap" -lt "$c" ] && c="$cap"
[ -n "$ul" ] && [ "$ul" -lt "$c" ] && c="$ul"
echo "$c"
}
# Room left right now: cgroup cap minus usage when capped, else MemAvailable.
headroom_mb() {
local cap used
cap=$(cgroup_cap_mb)
used=$(cgroup_used_mb)
if [ -n "$cap" ] && [ -n "$used" ]; then
echo $(( cap - used ))
else
avail_meminfo_mb
fi
}
# ── status ─────────────────────────────────────────────────────────────────
# Ask Windows for %USERPROFILE%; fall back to whichever profile owns a .wslconfig.
wslconfig_path() {
local profile winpath found
profile=$(cmd.exe /c "echo %USERPROFILE%" 2>/dev/null | tr -d "\r\n" || true)
case "$profile" in
""|*%*) ;;
*) winpath=$(wslpath -u "$profile" 2>/dev/null || true)
if [ -n "$winpath" ] && [ -d "$winpath" ]; then
echo "$winpath/.wslconfig"; return 0
fi ;;
esac
found=$(ls -d /mnt/c/Users/*/.wslconfig 2>/dev/null | head -1 || true)
[ -n "$found" ] && echo "$found"
return 0
}
hogs() {
echo " holding the most:"
ps -eo rss,comm --sort=-rss 2>/dev/null \
| awk 'NR>1 && NR<=6 {printf " %6.0f MB %s\n", $1/1024, $2}'
return 0
}
status() {
local total avail swap_total swap_free cap ul cur
echo "host"
echo " platform $(platform)"
echo " kernel $(uname -r)"
[ -r /etc/os-release ] && \
echo " distro $(sed -n 's/^PRETTY_NAME="\(.*\)"/\1/p' /etc/os-release)"
echo " cpu $(getconf _NPROCESSORS_ONLN 2>/dev/null || echo '?') online, load $(cut -d' ' -f1-3 /proc/loadavg)"
# ── the caps first, because they decide what the totals below are worth ──
echo
echo "caps"
cap=$(cgroup_cap_mb)
if [ -n "$cap" ]; then
cur=$(cgroup_used_mb)
echo " cgroup ${cap} MB (${CG_VERSION}, ${CG_CUR_FILE##*/} says ${cur:-?} MB used)"
echo " ! /proc/meminfo below describes the HOST, not this cgroup."
echo " $(mb MemTotal) MB total is not yours; ${cap} MB is."
elif [ -n "$CG_VERSION" ]; then
echo " cgroup none (${CG_VERSION} present, no memory limit set)"
else
echo " cgroup no memory controller found"
fi
ul=$(ulimit_v_mb)
if [ -n "$ul" ]; then
echo " ! ulimit -v ${ul} MB — a per-process cap, inherited from your shell"
echo " it stops this process long before the machine runs out"
else
echo " ulimit -v unlimited"
fi
# Overcommit mode decides whether limits show as failed mallocs or OOM kills.
local om or_
om=$(cat /proc/sys/vm/overcommit_memory 2>/dev/null || echo '?')
or_=$(cat /proc/sys/vm/overcommit_ratio 2>/dev/null || echo '?')
case "$om" in
0) echo " overcommit 0 heuristic — allocations are granted on a guess," ;;
1) echo " overcommit 1 always — every allocation succeeds; the OOM killer is the only limit," ;;
2) echo " overcommit 2 strict (ratio ${or_}%) — allocation fails honestly instead of killing later," ;;
*) echo " overcommit ${om}" ;;
esac
[ "$om" != "?" ] && echo " so RSS is the number to trust, not what a process asked for"
# ── what it says it has ──
total=$(mb MemTotal); avail=$(avail_meminfo_mb)
swap_total=$(mb SwapTotal); swap_free=$(mb SwapFree)
echo
echo "memory"
echo " total ${total} MB"
echo " available ${avail} MB"
echo " swap ${swap_total} MB ($(( swap_total - swap_free )) MB used)"
if [ "$swap_total" -eq 0 ]; then
echo " - no swap: this box has no cushion. It goes from fine to OOM-killed"
echo " with nothing in between, which is the abrupt failure you get in a VM."
fi
# postgres puts its shared buffers in /dev/shm. Docker's default is 64 MB,
# and the resulting failure names neither shm nor the size.
if [ -d /dev/shm ]; then
local shm; shm=$(df -Pm /dev/shm 2>/dev/null | awk 'NR==2{print $2}')
if [ -n "$shm" ]; then
if [ "$shm" -le 64 ]; then
echo " ! /dev/shm ${shm} MB — postgres puts shared memory here and 64 MB"
echo " is docker's default. Raise it with --shm-size when postgres fails."
else
echo " /dev/shm ${shm} MB"
fi
fi
fi
echo
echo "disk"
local d
for d in / /tmp /var/lib/docker; do
[ -d "$d" ] || continue
df -Pm "$d" 2>/dev/null | awk -v p="$d" 'NR==2{printf " %-12s %s MB free of %s MB\n", p, $4, $2}'
done
# kind and Tilt both watch large trees, and the failure mode is silent:
# they simply stop noticing file changes. Cheap to report while we are here.
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
echo "tooling"
echo " inotify watches=$w instances=$i"
if [ "$w" -lt 524288 ] || [ "$i" -lt 512 ]; then
echo " ! low — anything watching files will silently stop seeing changes"
fi
if ! command -v docker >/dev/null 2>&1; then
if [ -S /var/run/docker.sock ]; then
echo " docker socket present, no cli"
else
echo " docker not installed"
fi
elif docker info >/dev/null 2>&1; then
local n
n=$(docker ps -q 2>/dev/null | wc -l)
echo " docker $(docker version --format '{{.Server.Version}}' 2>/dev/null), ${n} container(s) running"
else
echo " ! docker cli present but the daemon is unreachable"
fi
# WSL: report the .wslconfig cap and whether it was applied (needs wsl --shutdown).
if is_wsl; then
local cfg conf conf_mb n
cfg=$(wslconfig_path)
echo
echo "wsl"
if [ -z "$cfg" ]; then
echo " ! cannot tell which Windows profile owns .wslconfig"
else
echo " config $cfg"
conf=$(configured_memory "$cfg")
if [ -n "$conf" ]; then
conf_mb=$(to_mb "$conf")
echo " configured $conf (${conf_mb} MB), booted ${total} MB"
# The VM reports a little less than allocated; 15% covers the
# kernel without calling every healthy machine a mismatch.
if [ -n "$conf_mb" ] && [ "$total" -lt $(( conf_mb * 85 / 100 )) ]; then
echo " ! configured ${conf_mb} MB but booted ${total} MB — not applied yet."
echo " From a WINDOWS terminal: wsl --shutdown then start the distro again."
fi
else
echo " configured no memory= set (WSL defaults to 50% of host RAM, or 8 GB,"
echo " whichever is less). To raise it, add on the Windows side:"
echo " [wsl2]"
echo " memory=8GB"
echo " then from a WINDOWS terminal: wsl --shutdown"
fi
n=$(ls "$cfg".*.bak 2>/dev/null | wc -l)
if [ "$n" -gt 0 ]; then
echo " backups $n (newest: $(ls -t "$cfg".*.bak 2>/dev/null | head -1))"
fi
fi
else
echo
echo " - native linux: no VM allocation to raise. If memory is tight the levers"
echo " are freeing something or adding swap."
fi
echo
echo "effective ceiling $(effective_ceiling_mb) MB"
echo " the lowest of MemTotal, the cgroup cap and ulimit -v. What the box"
echo " claims. 'push' measures what it will actually hand over."
[ "$avail" -lt $(( total / 5 )) ] && { echo; hogs; }
return 0
}
# ── .wslconfig ─────────────────────────────────────────────────────────────
require_wsl() {
if ! is_wsl; then
echo "$1 acts on .wslconfig, which only exists under WSL." >&2
echo "This is native Linux — there is no VM allocation to save or roll back." >&2
echo "Use 'status' to see what the machine actually has." >&2
exit 1
fi
}
# backup and restore act on the file, so unlike status they must not guess.
wslconfig_required() {
local cfg; cfg=$(wslconfig_path)
if [ -z "$cfg" ]; then
echo "cannot tell which Windows profile owns .wslconfig. Candidates:" >&2
ls -d /mnt/c/Users/*/ 2>/dev/null \
| grep -viE "/(All Users|Default|Default User|Public)/$" | sed "s/^/ /" >&2
exit 1
fi
echo "$cfg"
}
configured_memory() {
[ -r "$1" ] || { echo ""; return; }
sed -n 's/^[[:space:]]*memory[[:space:]]*=[[:space:]]*//p' "$1" | tail -1 | tr -d '[:space:]'
}
# "9GB" / "8192MB" / "9G" -> MB, so it can be compared with /proc/meminfo.
to_mb() {
local v="${1^^}" n
n=$(echo "$v" | tr -dc '0-9')
[ -n "$n" ] || { echo ""; return; }
case "$v" in
*GB|*G) echo $(( n * 1024 )) ;;
*MB|*M) echo "$n" ;;
*) echo $(( n / 1024 / 1024 )) ;;
esac
}
backup() {
require_wsl backup
local cfg dest
cfg=$(wslconfig_required)
[ -r "$cfg" ] || { echo "nothing to back up: $cfg does not exist" >&2; exit 1; }
# Timestamped, never overwritten.
dest="${cfg}.$(date +%Y%m%d-%H%M%S).bak"
cp "$cfg" "$dest"
echo "backed up $dest"
echo
echo "Edit $cfg by hand, then from a WINDOWS terminal: wsl --shutdown"
}
restore() {
require_wsl restore
local cfg newest count
cfg=$(wslconfig_required)
newest=$(ls -t "$cfg".*.bak 2>/dev/null | head -1 || true)
[ -n "$newest" ] || { echo "no backups found beside $cfg" >&2; exit 1; }
echo "restoring $newest"
echo " -> $cfg"
echo
# Restores the newest; list the others in case an older one is wanted.
count=$(ls "$cfg".*.bak 2>/dev/null | wc -l)
if [ "$count" -gt 1 ]; then
echo "$count backups exist, newest first:"
ls -t "$cfg".*.bak | sed 's/^/ /'
echo " (restoring the newest; copy another by hand to pick an older one)"
echo
fi
if [ -r "$cfg" ]; then
echo "what changes:"
if diff "$cfg" "$newest" > /tmp/mem.diff 2>&1 && [ ! -s /tmp/mem.diff ]; then
echo " nothing — that backup is identical to the current config"
else
sed 's/^/ /' /tmp/mem.diff
fi
rm -f /tmp/mem.diff
echo
fi
printf "proceed? [y/N] "
read -r reply
case "$reply" in
y|Y|yes|Yes) ;;
*) echo "left alone"; return 0 ;;
esac
cp "$newest" "$cfg"
echo "restored. From a WINDOWS terminal: wsl --shutdown"
}
# ── push ───────────────────────────────────────────────────────────────────
STATE=""
CHILD=""
cleanup() {
if [ -n "$CHILD" ] && kill -0 "$CHILD" 2>/dev/null; then
kill -KILL "$CHILD" 2>/dev/null || true
wait "$CHILD" 2>/dev/null || true
fi
[ -n "$STATE" ] && rm -f "$STATE"
return 0
}
# Runs as a child that may be OOM-killed; the parent survives to report.
allocator() {
# Make this process the preferred OOM victim (raising needs no privilege).
echo 1000 > "/proc/$BASHPID/oom_score_adj" 2>/dev/null || true
local arr=() held=0 i=0 rss swapped avail first_swap=0
local bytes=$((STEP_MB * 1024 * 1024))
local swap_used_start
swap_used_start=$(( $(mb SwapTotal) - $(mb SwapFree) ))
while :; do
# Write straight into the element (one copy, not three) and touch every page.
printf -v "arr[$i]" '%*s' "$bytes" ''
i=$((i + 1)); held=$((held + STEP_MB))
rss=$(awk '/^VmRSS:/{print int($2/1024)}' "/proc/$BASHPID/status" 2>/dev/null || echo 0)
avail=$(headroom_mb)
swapped=$(( $(mb SwapTotal) - $(mb SwapFree) - swap_used_start ))
[ "$swapped" -lt 0 ] && swapped=0
printf '%8s MB held rss %7s MB headroom %7s MB swap +%s MB\n' \
"$held" "$rss" "$avail" "$swapped"
printf '%s %s %s %s\n' "$held" "$rss" "$avail" "$swapped" >> "$STATE"
# First swap is reported separately: slow comes before killed.
if [ "$swapped" -gt 0 ] && [ "$first_swap" -eq 0 ]; then
first_swap=$held
echo " - first swap page at ${held} MB — past here it works but crawls"
echo "swapat $held" >> "$STATE"
fi
if [ -n "$TO_MB" ] && [ "$held" -ge "$TO_MB" ]; then
echo "stop reached-the-cap" >> "$STATE"; return 0
fi
if [ "$TO_OOM" = no ] && [ "$avail" -lt "$FLOOR_MB" ]; then
echo "stop floor" >> "$STATE"; return 0
fi
done
}
push() {
local total ceiling rc=0 last held rss swapat stop
total=$(mb MemTotal)
ceiling=$(effective_ceiling_mb)
# Default step: ceiling/64, clamped to 4..256 MB.
if [ "$STEP_EXPLICIT" = no ]; then
STEP_MB=$(( ceiling / 64 ))
[ "$STEP_MB" -lt 4 ] && STEP_MB=4
[ "$STEP_MB" -gt 256 ] && STEP_MB=256
fi
# Stop with a cushion: 64 MB under a cgroup cap, 512 MB on a host, or 5% of ceiling if larger.
if [ -n "$(cgroup_cap_mb)" ]; then FLOOR_MB=64; else FLOOR_MB=512; fi
[ $(( ceiling / 20 )) -gt "$FLOOR_MB" ] && FLOOR_MB=$(( ceiling / 20 ))
STATE=$(mktemp "${TMPDIR:-/tmp}/rigmini.XXXXXX")
trap cleanup EXIT
# Ctrl-C kills the child, frees the memory, and still prints the summary.
trap 'echo; echo " interrupted"; echo "stop interrupted" >> "$STATE"; [ -n "$CHILD" ] && kill -KILL "$CHILD" 2>/dev/null || true' INT
echo "push"
echo " step ${STEP_MB} MB per allocation, every page touched"
echo " ceiling ${ceiling} MB claimed"
if [ -n "$TO_MB" ]; then
echo " stopping at ${TO_MB} MB (--to)"
elif [ "$TO_OOM" = yes ]; then
echo " ! stopping only when the kernel stops it (--to-oom)"
echo " the allocating child is marked as the preferred OOM victim,"
echo " but nothing about an OOM kill is entirely polite. Not on a box"
echo " running anything you mind losing."
else
echo " stopping when headroom drops below ${FLOOR_MB} MB"
fi
echo
allocator &
CHILD=$!
wait "$CHILD" || rc=$?
CHILD=""
trap - INT
last=$(grep -E '^[0-9]' "$STATE" 2>/dev/null | tail -1 || true)
held=$(echo "$last" | awk '{print $1}')
rss=$(echo "$last" | awk '{print $2}')
swapat=$(awk '/^swapat/{print $2}' "$STATE" 2>/dev/null | head -1 || true)
stop=$(awk '/^stop/{print $2}' "$STATE" 2>/dev/null | head -1 || true)
echo
if [ -z "$held" ]; then
echo " ! nothing was allocated. Even one ${STEP_MB} MB chunk failed —"
echo " try a smaller --step, or check ulimit -v in 'status'."
return 1
fi
echo " reached ${rss:-$held} MB resident"
[ -n "$swapat" ] && echo " swapping from ${swapat} MB"
case "$stop" in
reached-the-cap)
echo " outcome stopped at the --to cap, not at a limit."
echo " The box held ${TO_MB} MB without complaint; there is more." ;;
floor)
echo " outcome stopped with a cushion intact, by choice."
echo " The real ceiling is higher — --to-oom finds it, at the"
echo " cost of an actual OOM kill." ;;
interrupted)
echo " outcome interrupted at ${rss:-$held} MB — where you stopped it,"
echo " not where the box did." ;;
*)
# No stop line means the child did not decide to stop: it was ended.
if [ "$rc" -ge 128 ]; then
echo " outcome the child was killed (signal $((rc - 128))) at ${rss:-$held} MB."
elif [ "$rc" -ne 0 ]; then
echo " outcome the allocation failed at ${rss:-$held} MB (exit ${rc})."
echo " bash could not get the next chunk — an honest malloc"
echo " failure rather than a kill. That is the strict-overcommit"
echo " or ulimit path."
else
echo " outcome ended at ${rss:-$held} MB."
fi
local ev
ev=$(dmesg 2>/dev/null | tail -80 | grep -iE 'oom-kill|killed process' | tail -1 || true)
if [ -n "$ev" ]; then
echo " kernel ${ev#*] }"
else
echo " - dmesg is unreadable here (dmesg_restrict, or no privilege),"
echo " so the kill cannot be confirmed from this side. The number stands."
fi ;;
esac
# Warn about claimed-vs-measured gap only when the box, not us, chose the stop.
local got="${rss:-$held}"
echo
if [ -z "$stop" ] && [ "$got" -lt $(( ceiling * 70 / 100 )) ]; then
echo " ! claimed ${ceiling} MB, gave up ${got} MB — under 70% of it."
echo " Something is taking the difference. 'status' names the candidates:"
echo " a cgroup cap, ulimit -v, or memory already resident."
fi
return 0
}
# ── all ────────────────────────────────────────────────────────────────────
all() {
status
echo
echo "────────────────────────────────────────────────────────────"
echo
push
local got budget_mb ceiling
load_config
if [ -n "$BUDGET_GB" ]; then
budget_mb=$(( BUDGET_GB * 1024 ))
else
budget_mb=$(( NODES * NODE_MB ))
fi
ceiling=$(effective_ceiling_mb)
got=$(grep -E '^[0-9]' "$STATE" 2>/dev/null | tail -1 | awk '{print $2}' || true)
[ -n "$got" ] || got=0
echo
echo "verdict"
if [ -n "$BUDGET_GB" ]; then
echo " budget ${budget_mb} MB (--budget)"
else
# rig's own figure for this profile: nodes times what one node costs.
# Addons carry no memory figure in rig yet, so this is the cluster alone
# and whatever you deploy comes on top. --budget once you know that too.
echo " budget ${budget_mb} MB — profile ${PROFILE_NAME}: ${NODES} node(s) x ${NODE_MB} MB,"
echo " the cluster alone; your workload comes on top (--budget GB)"
fi
echo " measured ${got} MB handed over"
if [ "$got" -ge "$budget_mb" ]; then
echo " fits, with $(( got - budget_mb )) MB spare."
if [ "$got" -lt $(( budget_mb * 130 / 100 )) ]; then
echo " - under 30% spare is thin once a workload runs on top: memory use"
echo " is spiky, and the spikes are what get killed."
fi
else
echo " ! short by $(( budget_mb - got )) MB."
if [ "$ceiling" -ge "$budget_mb" ]; then
echo " The box CLAIMS enough (${ceiling} MB) but did not deliver it."
echo " Free something, or read the caps section again."
else
echo " The box does not have it to give. A bigger machine, or a profile"
echo " with fewer nodes."
fi
fi
return 0
}
# ── main ───────────────────────────────────────────────────────────────────
parse_flags() {
while [ $# -gt 0 ]; do
case "$1" in
--to) TO_MB=$(( ${2:?--to needs a value in GB} * 1024 )); shift 2 ;;
--to-mb) TO_MB="${2:?--to-mb needs a value in MB}"; shift 2 ;;
--step) STEP_MB="${2:?--step needs a value in MB}"; STEP_EXPLICIT=yes; shift 2 ;;
--to-oom) TO_OOM=yes; shift ;;
--budget) BUDGET_GB="${2:?--budget needs a value in GB}"; BUDGET_EXPLICIT=yes; shift 2 ;;
*) echo "unknown argument: $1" >&2; exit 1 ;;
esac
done
if [ "$TO_OOM" = yes ] && [ -n "$TO_MB" ]; then
echo "--to and --to-oom contradict each other: one stops early, the other" >&2
echo "refuses to stop at all. Pick one." >&2
exit 1
fi
return 0
}
require_linux
find_cgroup
cmd="${1:-status}"
[ $# -gt 0 ] && shift
case "$cmd" in
status) parse_flags "$@"; status ;;
push) parse_flags "$@"; push ;;
all) parse_flags "$@"; all ;;
backup) backup ;;
restore) restore ;;
*) echo "usage: $0 [status|push|all|backup|restore]" >&2
echo " push [--to GB] [--to-mb MB] [--step MB] [--to-oom]" >&2
echo " all [--budget GB]" >&2
exit 1 ;;
esac

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

@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# Give each environment its own block of host ports, derived from the directory name.
# base = 20000 + (hash(slug) % 200) * 10; +0 HTTP +1 HTTPS +2 TILT +3 REGISTRY
# Usage: ports.sh show | active | derive | persist
# Notes: docs/notes/ports.md
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))
}
# Resolved facts for consumers outside bash, space-separated, positional:
# CLUSTER KUBECONTEXT HTTP HTTPS TILT REGISTRY MANIFESTS_DIR OVERLAY_DIR
# The two paths are absolute, or - when there is none. Read this, not `derive`.
active() {
load_config
local m="-" o="-"
if [ -n "$MANIFESTS_DIR" ]; then m=$(_abs_from_ctrl "$MANIFESTS_DIR"); fi
if [ -n "$OVERLAY_DIR" ]; then o=$(_abs_from_ctrl "$OVERLAY_DIR"); fi
case "$m$o" in
*[[:space:]]*)
echo "a path here holds whitespace, and these facts are split on spaces: $m $o" >&2
exit 1 ;;
esac
echo "$CLUSTER $KUBECONTEXT $HTTP_PORT $HTTPS_PORT $TILT_PORT $REGISTRY_PORT $m $o"
}
show() {
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
# ctrl/.env belongs to this rig, not to an overlay: a pin written now would
# follow every overlay this rig later runs, and two of them would collide.
if [ -n "${OVERLAY:-}" ]; then
echo "not pinning: OVERLAY is set, and ctrl/.env would carry this block to every overlay" >&2
echo " its ports stay derived from its folder name ($CLUSTER): $DERIVED_HTTP-$DERIVED_REGISTRY" >&2
exit 1
fi
[ -f ./.env ] || cp ./.env.example ./.env
local wrote=0 key val
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" ;;
active) active ;;
persist) persist ;;
*) echo "usage: $0 [show|active|derive|persist]" >&2; exit 1 ;;
esac

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

@@ -0,0 +1,186 @@
#!/usr/bin/env bash
# Registry plumbing: REGISTRY_MODE none | local | mirror | remote. A script, not ctlptl,
# because ctlptl cannot express `mirror`.
# Usage: registry.sh up | down | status
# Notes: docs/notes/registry.md
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 ───────────────────────────────────────────────────────────────
# Copy REGISTRY_CA_FILE into every kind node's trust store (nodes don't inherit host trust).
install_ca_into_nodes() {
[ -n "${REGISTRY_CA_FILE:-}" ] || return 0
if [ ! -r "$REGISTRY_CA_FILE" ]; then
echo "REGISTRY_CA_FILE is set but not readable: $REGISTRY_CA_FILE" >&2
exit 1
fi
echo " distributing CA to kind nodes"
local node
for node in $(kind get nodes --name "$CLUSTER"); do
docker cp "$REGISTRY_CA_FILE" "$node:/usr/local/share/ca-certificates/corp-registry.crt"
docker exec "$node" update-ca-certificates >/dev/null 2>&1
docker exec "$node" systemctl restart containerd
done
}
# Point containerd at a registry host. The cluster config already set
# config_path=/etc/containerd/certs.d, so this is a per-node drop-in and needs no
# cluster recreate — which is what lets registry mode change on a live cluster.
write_hosts_toml() {
local host="$1" upstream="$2" skip_verify="${3:-false}"
local node
for node in $(kind get nodes --name "$CLUSTER"); do
docker exec "$node" mkdir -p "/etc/containerd/certs.d/${host}"
docker exec -i "$node" cp /dev/stdin "/etc/containerd/certs.d/${host}/hosts.toml" <<TOML
server = "${upstream}"
[host."${upstream}"]
capabilities = ["pull", "resolve"]
skip_verify = ${skip_verify}
TOML
done
}
# ── the local container (local + mirror) ───────────────────────────────────
start_registry_container() {
if [ "$(docker inspect -f '{{.State.Running}}' "$REG_NAME" 2>/dev/null || true)" = "true" ]; then
echo " registry container '$REG_NAME' already running"
return
fi
docker rm -f "$REG_NAME" >/dev/null 2>&1 || true
local args=(-d --restart=always --name "$REG_NAME"
-p "127.0.0.1:${REG_PORT}:5000")
if [ "$REGISTRY_MODE" = "mirror" ]; then
if [ -z "${REGISTRY_REMOTE_URL:-}" ]; then
echo "REGISTRY_MODE=mirror needs REGISTRY_REMOTE_URL in ctrl/.env" >&2
exit 1
fi
echo " starting pull-through cache of ${REGISTRY_REMOTE_URL}"
args+=(-e "REGISTRY_PROXY_REMOTEURL=${REGISTRY_REMOTE_URL}")
[ -n "${REGISTRY_USER:-}" ] && args+=(-e "REGISTRY_PROXY_USERNAME=${REGISTRY_USER}")
[ -n "${REGISTRY_PASSWORD:-}" ] && args+=(-e "REGISTRY_PROXY_PASSWORD=${REGISTRY_PASSWORD}")
if [ -n "${REGISTRY_CA_FILE:-}" ]; then
args+=(-v "$(readlink -f "$REGISTRY_CA_FILE"):/etc/ssl/certs/corp-ca.crt:ro")
fi
else
echo " starting local registry"
fi
docker run "${args[@]}" "$REGISTRY_IMAGE" >/dev/null
}
# The registry must share a network with the nodes so they can resolve it by
# container name; localhost inside a node is the node, not the host.
join_kind_network() {
if docker inspect -f '{{json .NetworkSettings.Networks}}' "$REG_NAME" | grep -q '"kind"'; then
return
fi
docker network connect kind "$REG_NAME" >/dev/null 2>&1 || true
}
# The documented contract that tells tooling (Tilt, skaffold) where the local
# registry is, so they don't have to be configured separately.
apply_hosting_configmap() {
$K apply -f - <<YAML >/dev/null
apiVersion: v1
kind: ConfigMap
metadata:
name: local-registry-hosting
namespace: kube-public
data:
localRegistryHosting.v1: |
host: "localhost:${REG_PORT}"
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
YAML
}
# ── modes ──────────────────────────────────────────────────────────────────
up() {
echo "registry: ${REGISTRY_MODE}"
case "$REGISTRY_MODE" in
none)
echo " no registry — images are built straight into the node"
;;
local|mirror)
start_registry_container
join_kind_network
install_ca_into_nodes
# Nodes reach the registry by container name on the shared network;
# the host reaches it on localhost:PORT. Both names must resolve.
write_hosts_toml "localhost:${REG_PORT}" "http://${REG_NAME}:5000"
if [ "$REGISTRY_MODE" = "mirror" ]; then
# Anything asking for docker.io transparently goes to the cache.
write_hosts_toml "docker.io" "http://${REG_NAME}:5000"
fi
apply_hosting_configmap
echo " ready at localhost:${REG_PORT}"
;;
remote)
if [ -z "${REGISTRY_REMOTE_URL:-}" ]; then
echo "REGISTRY_MODE=remote needs REGISTRY_REMOTE_URL in ctrl/.env" >&2
exit 1
fi
install_ca_into_nodes
local host="${REGISTRY_REMOTE_URL#*://}"; host="${host%%/*}"
if [ -n "${REGISTRY_USER:-}" ]; then
echo " creating imagePullSecret for ${host}"
$K create secret docker-registry regcred \
--docker-server="$host" \
--docker-username="$REGISTRY_USER" \
--docker-password="$REGISTRY_PASSWORD" \
--dry-run=client -o yaml | $K apply -f - >/dev/null
# Attach to the default ServiceAccount so plain pods inherit it.
$K patch serviceaccount default \
-p '{"imagePullSecrets":[{"name":"regcred"}]}' >/dev/null
fi
echo " pulling directly from ${host}"
;;
*)
echo "unknown REGISTRY_MODE '$REGISTRY_MODE' (expected none|local|mirror|remote)" >&2
exit 1
;;
esac
}
down() {
if docker inspect "$REG_NAME" >/dev/null 2>&1; then
echo "removing registry container '$REG_NAME'"
docker rm -f "$REG_NAME" >/dev/null
fi
}
status() {
echo "mode ${REGISTRY_MODE}"
if docker inspect "$REG_NAME" >/dev/null 2>&1; then
echo "container ${REG_NAME} $(docker inspect -f '{{.State.Status}}' "$REG_NAME")"
echo "endpoint localhost:${REG_PORT}"
else
echo "container none"
fi
[ -n "${REGISTRY_REMOTE_URL:-}" ] && echo "upstream ${REGISTRY_REMOTE_URL}"
[ -n "${REGISTRY_CA_FILE:-}" ] && echo "ca ${REGISTRY_CA_FILE}"
return 0
}
case "${1:-status}" in
up) up ;;
down) down ;;
status) status ;;
*) echo "usage: $0 [up|down|status]" >&2; exit 1 ;;
esac

398
rig/ctrl/selftest.sh Executable file
View File

@@ -0,0 +1,398 @@
#!/usr/bin/env bash
# What rig has settled, written down as assertions: one decision per check.
# No cluster, docker or network; exits 1 on failure (unlike `make check`).
# Usage: make selftest [install] (or: bash ctrl/selftest.sh [install])
# install: the installer in clean containers — docker, network, minutes (installtest.sh)
# Notes: docs/notes/selftest.md
set -uo pipefail # NOT -e: one failing check must not abort the rest
cd "$(dirname "$0")"
if [ "${1:-}" = install ]; then shift; exec bash ./installtest.sh "$@"; fi
source ./lib/config.sh
rc=0
passed=0
check() { # name, expected, actual
if [ "$2" = "$3" ]; then
printf ' ok %s\n' "$1"
passed=$((passed + 1))
else
printf ' FAIL %s\n expected: %s\n got: %s\n' "$1" "$2" "$3"
rc=1
fi
}
note() { printf '\n%s\n' "$1"; }
# A scratch copy of rig for a check to change freely. local/ (overlays, possibly
# someone else's) and def/ (scratch) never ride along, and neither do this
# machine's PROFILE/OVERLAY/CLUSTER choices: a check sets what it tests.
copy_rig() { # dest-dir
mkdir -p "$1"
tar -C .. --exclude=./local --exclude=./def -cf - . | tar -C "$1" -xf -
if [ -f "$1/ctrl/.env" ]; then
sed -i '/^PROFILE=/d; /^OVERLAY=/d; /^CLUSTER=/d; /^MANIFESTS_DIR=/d' "$1/ctrl/.env"
fi
}
# Resolve one key the way every rig script does, in a clean shell so the
# caller's exported value is the only thing in play.
resolved() {
bash -c 'source ./lib/config.sh; load_config >/dev/null 2>&1; printf "%s" "${!1}"' _ "$1"
}
note "rig needs no profile"
# No env.d/ must still resolve and generate a kit; an unknown profile stays an error.
NP="$(mktemp -d)"
copy_rig "$NP/rig"; rm -rf "$NP/rig/ctrl/env.d"
check "no env.d: config resolves" "default" \
"$(cd "$NP/rig/ctrl" && bash -c 'source ./lib/config.sh; load_config >/dev/null && echo "$PROFILE_NAME"' 2>&1)"
check "no env.d: the k8s version comes from the pins" "yes" \
"$(cd "$NP/rig/ctrl" && bash -c 'source ./lib/config.sh; load_config >/dev/null && [ -n "$NODE_IMAGE" ] && echo yes' 2>&1)"
check "no env.d: ports.sh active works" "8" \
"$(cd "$NP/rig/ctrl" && bash ports.sh active 2>/dev/null | wc -w)"
check "no env.d: a kit is generated for the defaults" "yes" \
"$( (cd "$NP/rig/ctrl" && rm -rf ../standalone/*/ && bash standalone.sh write >/dev/null 2>&1) && [ -f "$NP/rig/standalone/default/rigdeps.sh" ] && echo yes || echo no)"
check "a profile that does not exist is still an error" "yes" \
"$( (cd "$NP/rig/ctrl" && PROFILE=no-such-profile bash -c 'source ./lib/config.sh; load_config' >/dev/null 2>&1) && echo no || echo yes)"
rm -rf "$NP"
note "the ports.sh active contract"
# ports.sh active is read positionally by the Makefile and Tiltfile: pin field count and order.
FACTS="$(bash ports.sh active)"
check "active: exactly 8 fields" "8" "$(printf '%s' "$FACTS" | wc -w)"
read -r F_CLUSTER F_CTX F_HTTP F_HTTPS F_TILT F_REG F_MANIFESTS F_OVERLAY <<< "$FACTS"
check "active: field 2 is kind-<cluster>" "kind-$F_CLUSTER" "$F_CTX"
check "active: fields 3-6 are numeric" "yes" \
"$([[ "$F_HTTP$F_HTTPS$F_TILT$F_REG" =~ ^[0-9]+$ ]] && echo yes || echo no)"
# Absolute, or - when there is none: an empty field would shift every later one.
check "active: fields 7-8 are absolute paths or -" "yes" \
"$(for f in "$F_MANIFESTS" "$F_OVERLAY"; do case "$f" in -|/*) ;; *) echo no; exit; esac; done; echo yes)"
# derive answers a different question and must keep its own shape: it reports
# what the directory name implies, ignoring ctrl/.env, so nothing should
# configure itself from it.
check "derive: still 4 fields, not 7" "4" "$(bash ports.sh derive | wc -w)"
note "the caller's env beats the files"
# Every key in CONFIG_OVERRIDABLE must lose to the caller's env; the loop follows the list.
test_value() {
case "$1" in
# Picked from what exists, never named: rig must not need any particular
# profile, template or pinned version to be present for this to run.
PROFILE) config_profiles | head -1 ;;
K8S_VERSION) (set -a; source ./versions.env; compgen -v NODE_IMAGE_v | sort -V | head -1 | sed 's/^NODE_IMAGE_//') ;;
# An absolute path, as a project passing its own file does. Never equal
# to the default, so the check cannot pass by accident.
KIND_CONFIG) echo "$PWD/k8s/kind-config.yaml.tpl" ;;
*_PORT) echo "19999" ;;
CLUSTER) echo "selftest-name" ;;
# Named folders must exist, and must not be the default.
MANIFESTS_DIR) echo "examples/starter/k8s/base" ;;
OVERLAY) echo "examples/data" ;;
ADDONS) echo "metallb" ;;
*) echo "selftest-sentinel" ;;
esac
}
for key in $CONFIG_OVERRIDABLE; do
[ -n "$key" ] || continue
want="$(test_value "$key")"
if [ -z "$want" ]; then
check "precedence: $key has a test value" "yes" "no — add one to test_value()"
continue
fi
got="$(export "$key=$want"; resolved "$key")"
check "precedence: caller's $key wins" "$want" "$got"
done
note "one derivation, not three"
# The Makefile must take context/port from ports.sh active, checked on real `make -n` output.
# --no-print-directory + grep, not tail -1: under `make selftest` this is a recursive make.
MK="$(cd .. && make --no-print-directory -n tilt 2>/dev/null | grep -m1 'tilt ')"
check "Makefile: --context comes from active" "$F_CTX" \
"$(printf '%s' "$MK" | sed -n 's/.*--context \([^ ]*\).*/\1/p')"
check "Makefile: --port comes from active" "$F_TILT" \
"$(printf '%s' "$MK" | sed -n 's/.*--port \([^ ]*\).*/\1/p')"
note "identity follows the folder, safely"
# The cluster name is the folder name made a DNS label, derived only in lib/config.sh.
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
mkdir -p "$TMP/My_Proj"
cp -r . "$TMP/My_Proj/ctrl"
# A pinned CLUSTER in .env would be an override, not a derivation, and this
# check is about the derivation. (An OVERLAY would be another derivation.)
sed -i '/^CLUSTER=/d; /^OVERLAY=/d' "$TMP/My_Proj/ctrl/.env" 2>/dev/null
COPY="$(cd "$TMP/My_Proj/ctrl" && bash ports.sh active)"
check "a dir named My_Proj derives a DNS label" "my-proj" "$(awk '{print $1}' <<< "$COPY")"
check "and a context to match" "kind-my-proj" "$(awk '{print $2}' <<< "$COPY")"
check "a renamed copy gets a DIFFERENT block" "different" \
"$([ "$(awk '{print $3}' <<< "$COPY")" != "$F_HTTP" ] && echo different || echo COLLIDES)"
note "ports are stable across versions"
# Ports are derived, never stored: a changed derivation moves every existing env's ports.
check "derive_port_base rig" "20310" "$(derive_port_base rig)"
check "derive_port_base foo" "21690" "$(derive_port_base foo)"
check "derive_port_base my-proj" "21030" "$(derive_port_base my-proj)"
note "rig stays standalone"
# rig must be copyable out of its host project: no references to the host.
# The pattern is assembled from fragments so this file does not match itself.
# The host project's word for a backing service counts too: rig described its
# workload addons with it until they left. local/ holds overlays, which may say anything.
HOST_PAT="$(printf '%s' 'sole' 'print' '|\b' 'sp' 'r\b' '|' 'cab' 'inet')"
check "no host-project references" "0" \
"$(cd .. && grep -rIl -iE "$HOST_PAT" . --exclude-dir=def --exclude-dir=local 2>/dev/null | wc -l)"
note "what runs is an overlay; rig only reads it"
# docs/notes/overlay.md. Every check runs in a scratch copy with its own overlay.
OV="$TMP/overlay-proof"; copy_rig "$OV/rig"
OVR="$OV/rig"
mkdir -p "$OVR/local/My_Env/addons" "$OVR/local/My_Env/k8s/prod" "$OVR/ctrl/env.d"
printf 'ADDONS="from-profile"\nDATA_NAMESPACE=from-profile\n' > "$OVR/ctrl/env.d/selftest.env"
cat > "$OVR/local/My_Env/rig.env" <<'EOF'
ADDONS="metallb"
DATA_NAMESPACE=from-overlay
MANIFESTS_DIR=k8s/prod
SELFTEST_SENTINEL=selftest-overlay-sentinel
EOF
printf 'resources: []\n' > "$OVR/local/My_Env/k8s/prod/kustomization.yaml"
printf '#!/usr/bin/env bash\necho "overlay-metallb from $PWD with ${RIG_CTRL:-no RIG_CTRL}"\n' \
> "$OVR/local/My_Env/addons/metallb.sh"
in_ov() { (cd "$OVR/ctrl" && "$@"); }
ov_key() { # key [env assignments...]
local k="$1"; shift
in_ov env "$@" bash -c 'source ./lib/config.sh; load_config >/dev/null 2>&1; printf "%s" "${!1}"' _ "$k"
}
# With nothing named, rig behaves as it did before overlays: same name, ports, addons, nodes.
check "no overlay: the cluster, ports and addons of before" "rig kind-rig 20310 20311 20312 20313" \
"$(in_ov bash ports.sh active | awk '{print $1, $2, $3, $4, $5, $6}')"
check "no overlay: no addons, one node, rig's own kind config" "|1|./k8s/kind-config.yaml.tpl" \
"$(ov_key ADDONS)|$(ov_key NODES)|$(ov_key KIND_CONFIG)"
# The overlay's rig.env sits between the profile and ctrl/.env; the caller beats all.
check "rig.env beats the profile" "from-overlay" \
"$(ov_key DATA_NAMESPACE PROFILE=selftest OVERLAY=local/My_Env)"
echo 'DATA_NAMESPACE=from-dotenv' >> "$OVR/ctrl/.env"
check "ctrl/.env beats rig.env" "from-dotenv" \
"$(ov_key DATA_NAMESPACE PROFILE=selftest OVERLAY=local/My_Env)"
sed -i '/^DATA_NAMESPACE=from-dotenv$/d' "$OVR/ctrl/.env"
check "the caller beats rig.env" "from-caller" \
"$(ov_key ADDONS OVERLAY=local/My_Env ADDONS=from-caller)"
# Identity follows the overlay's folder, so one rig serves several without collisions.
check "identity follows the overlay's folder" "my-env kind-my-env" \
"$(in_ov env OVERLAY=local/My_Env bash ports.sh active | awk '{print $1, $2}')"
check "paths in rig.env are relative to the overlay" "$OVR/local/My_Env/k8s/prod" \
"$(in_ov env OVERLAY=local/My_Env bash ports.sh active | awk '{print $7}')"
check "a named overlay that does not exist is an error" "yes" \
"$(in_ov env OVERLAY=local/nope bash ports.sh active >/dev/null 2>&1 && echo no || echo yes)"
printf 'PROFILE=x\n' > "$OV/bad-rig.env"; mkdir -p "$OVR/local/bad"; cp "$OV/bad-rig.env" "$OVR/local/bad/rig.env"
check "rig.env may not choose the profile or the overlay" "yes" \
"$(in_ov env OVERLAY=local/bad bash ports.sh active >/dev/null 2>&1 && echo no || echo yes)"
# Addons: the overlay's is found before rig's own, and runs from rig's ctrl/.
check "an overlay's addon comes before rig's of the same name" \
"overlay-metallb from $OVR/ctrl with $OVR/ctrl" \
"$(in_ov env OVERLAY=local/My_Env ADDONS=metallb bash addons.sh install 2>&1 | grep '^overlay-metallb')"
# ctrl/.env is this rig's: a pinned block would follow every overlay.
check "ports.sh persist refuses while an overlay is set" "yes" \
"$(in_ov env OVERLAY=local/My_Env bash ports.sh persist >/dev/null 2>&1 && echo no || echo yes)"
# make's $(shell) must see an OVERLAY given as a make argument (make < 4.4 does not pass it).
check "make -n tilt OVERLAY=... asks for the overlay's context" "kind-data" \
"$(cd .. && make --no-print-directory -n tilt OVERLAY=examples/data 2>/dev/null | grep -m1 'tilt ' | sed -n 's/.*--context \([^ ]*\).*/\1/p')"
# rig reads an overlay and never writes into it; its values never reach a committed kit.
sum_ov() { (cd "$OVR/local/My_Env" && find . -type f | sort | xargs sha256sum | sha256sum); }
before=$(sum_ov)
echo 'OVERLAY=local/My_Env' >> "$OVR/ctrl/.env"
in_ov bash ports.sh active >/dev/null 2>&1
in_ov bash addons.sh list >/dev/null 2>&1
in_ov bash -c 'source ./lib/config.sh; load_config >/dev/null; render_kind_config >/dev/null' 2>/dev/null
in_ov bash standalone.sh write >/dev/null 2>&1
in_ov bash standalone.sh export "$OV/export" >/dev/null 2>&1
check "rig writes nothing into an overlay" "$before" "$(sum_ov)"
check "an overlay's values never reach a committed kit" "0" \
"$(grep -rlE 'selftest-overlay-sentinel|local/My_Env' "$OVR/standalone" 2>/dev/null | wc -l)"
check "a committed kit holds no path of this machine" "0" \
"$(grep -rlF "$OVR" "$OVR/standalone" 2>/dev/null | wc -l)"
note "the Tiltfile hardcodes nothing"
# The Tiltfile asks ports.sh for its context; a literal kind-<name> would undo that.
check "no literal kind-<name>" "0" "$(grep -cE "['\"]kind-[a-z0-9]" Tiltfile)"
check "guards on the variable" "1" "$(grep -c 'allow_k8s_contexts(CTX)' Tiltfile)"
check "asks ports.sh for facts" "1" "$(grep -c "local('bash ports.sh active'" Tiltfile)"
check "hands over to the overlay's Tiltfile" "1" "$(grep -c "include(OVERLAY + '/Tiltfile')" Tiltfile)"
note "standalone kits are generated, current, and call only real verbs"
# A kit left stale by a change to rig fails here, not on another machine.
check "every kit matches what rig generates now" "yes" \
"$(bash standalone.sh check >/dev/null 2>&1 && echo yes || echo "no — run make standalone")"
# Every kit Makefile target must call a verb its script's own dispatch accepts.
verbs_of() {
sed -n '/^case "\$cmd" in/,/^esac/p' "$1" | grep -oE '^ [a-z]+\)' | tr -d ' )'
}
kits=0
for mk in ../standalone/*/Makefile; do
[ -f "$mk" ] || continue
kit=$(dirname "$mk"); kits=$((kits + 1))
for target in $(grep -oE '^[a-z][a-z-]*:' "$mk" | tr -d ':' | grep -vx help); do
line="$(make --no-print-directory -s -n -f "$mk" "$target" 2>/dev/null | head -1)"
script=$(basename "$(printf '%s' "$line" | awk '{print $2}')")
verb=$(printf '%s' "$line" | awk '{print $NF}')
check "$(basename "$kit"): make $target -> $script $verb, a verb it accepts" "yes" \
"$(verbs_of "$kit/$script" | grep -qx "$verb" && echo yes || echo "no: '$verb'")"
done
check "$(basename "$kit"): no \`mini\` target, which already means minimal footprint" "0" \
"$(grep -cE '^mini:' "$mk")"
done
check "there is a kit for every profile" "$(config_profiles | wc -l)" "$kits"
# An export carries this machine's choices but never its credentials; committed kits carry neither.
# Proven with sentinel values in a scratch copy, since the real ctrl/.env may leave them empty.
SX="$TMP/export-proof"; copy_rig "$SX/rig"
mkdir -p "$SX/selftest-sentinel-choice/overlays/dev" # a named MANIFESTS_DIR must exist
cat >> "$SX/rig/ctrl/.env" <<'EOF'
REGISTRY_USER=selftest-sentinel-user
REGISTRY_PASSWORD=selftest-sentinel-password
MANIFESTS_DIR=../selftest-sentinel-choice/overlays/dev
EOF
( cd "$SX/rig/ctrl" && bash standalone.sh export "$SX/out" >/dev/null 2>&1 )
count_in() { grep -rcF -- "$1" "$2" 2>/dev/null | awk -F: '{s+=$2} END{print s+0}'; }
check "export: carries this machine's choices" "yes" \
"$([ "$(count_in selftest-sentinel-choice "$SX/out")" -gt 0 ] && echo yes || echo no)"
check "export: carries no credential" "0" \
"$(( $(count_in selftest-sentinel-user "$SX/out") + $(count_in selftest-sentinel-password "$SX/out") ))"
check "per-profile kits: carry neither, whatever this machine has" "0" \
"$( (cd "$SX/rig/ctrl" && source ./lib/config.sh && for p in $(config_profiles); do config_snapshot "$p"; done) \
| grep -cE 'selftest-sentinel-(choice|user|password)')"
check "export: refuses to write inside the repository" "yes" \
"$( (bash standalone.sh export ../standalone/selftest-mine >/dev/null 2>&1) && echo no || echo yes)"
note "rig's addons apply verified files, never URLs"
# The offline profile must need no network for manifests: each addon asks deps.sh for a
# pinned manifest, verified on disk (versions.md). Their images still need preloading.
check "no rig addon applies a URL" "0" \
"$(cat addons/*.sh | grep -cE 'apply -f "?https?://')"
check "every manifest an addon asks for is pinned with a sum" "" \
"$(for n in $(grep -ohE 'deps\.sh manifest [A-Z_]+' addons/*.sh | awk '{print $3}' | sort -u); do
grep -q "^${n}_MANIFEST_URL=" versions.env && grep -q "^${n}_MANIFEST_SHA256=[0-9a-f]\{64\}$" versions.env \
|| printf '%s ' "$n"; done)"
note "withdrawn stays withdrawn (STALE.md)"
# One check per entry; the reasoning is in STALE.md, not here.
check "✖ S1 rig's Tiltfile has no Images section of its own" "0" "$(grep -c '^# ── Images' Tiltfile)"
check "✖ S2 local/ is where overlays live, and ignored" "yes" \
"$(grep -qx '/local/' ../.gitignore && echo yes || echo no)"
# Patterns assembled from fragments so this file does not match itself.
COPIES_PAT="$(printf '%s' 'ac' 'me-rig|ac' 'mebank')"
HOUSE_PAT="$(printf '%s' 'semes' 'ter|local' '\.ar\b')"
check "✖ S2 no example environment name from the copies era" "0" \
"$(cd .. && grep -rIlE "$COPIES_PAT" . --exclude-dir=def --exclude-dir=local --exclude=STALE.md 2>/dev/null | wc -l)"
check "✖ S3 ctrl/addons makes the cluster work, nothing more" "cert-manager metallb metrics-server" \
"$(ls addons/ | sed 's/\.sh$//' | sort | xargs)"
check "✖ S3 versions.env pins no workload image" "0" \
"$(grep -cE '^(POSTGRES|REDIS|AIRFLOW)_IMAGE=' versions.env)"
check "✖ S4 no namespace named after the cluster" "0" "$(grep -c "CLUSTER + ':namespace'" Tiltfile)"
check "✖ S5 rig's examples left ctrl/k8s" "no" "$([ -d k8s/overlays ] && echo yes || echo no)"
check "✖ S5 .env.example does not pin MANIFESTS_DIR" "0" "$(grep -c '^MANIFESTS_DIR=' .env.example)"
check "✖ S6 no client or data example profile" "0" \
"$(ls env.d/ | grep -cE '^(client|data)\.')"
check "✖ S7 no house path or host name in rig" "0" \
"$(cd .. && grep -rIlE "$HOUSE_PAT" . --exclude-dir=def --exclude-dir=local --exclude=STALE.md 2>/dev/null | wc -l)"
note "the installer detects what each kind of machine needs"
# Host fixtures (tests/hosts/): a stand-in root per machine, detect run against it. The
# expensive machines — the Workspace, a WSL install — are exactly the ones you cannot
# rebuild to test on, so their shapes are replayed here instead.
out="$(bash ./hosttest.sh 2>&1)"
check "every host fixture detects as expected" "$(ls -d ../tests/hosts/*/ | wc -l) host fixture(s) as expected, 0 not" \
"$(tail -1 <<< "$out")"
# A snapshot is carried off a machine that may be someone else's: it must hold only the
# files detect reads, and nothing that names the machine or the person.
SN="$TMP/snapshot/s"
bash ./deps.sh snapshot "$SN" >/dev/null 2>&1
check "snapshot writes only what detect reads" \
"expect.txt facts.txt root/etc/os-release root/proc/meminfo root/proc/sys/vm/overcommit_memory root/proc/version" \
"$( (cd "$SN" 2>/dev/null && find . -type f | sed 's|^\./||' | grep -vE '^root/(etc/wsl\.conf|mnt/c/Users/user/\.wslconfig)$' | sort | xargs) )"
check "snapshot names no host, user or home" "0" \
"$(grep -rlE "$(hostname)|${USER:-nobody}|/home/" "$SN" 2>/dev/null | wc -l)"
check "and replays as the machine it was taken on" "yes" \
"$(bash ./hosttest.sh "$SN" >/dev/null 2>&1 && echo yes || echo no)"
note "the dev loop parses — needs tilt and kubectl, not a cluster"
# A throwaway kubeconfig with kind-named entries (Tilt trusts kind contexts) and a
# kubectl that swallows `apply`: the Tiltfile evaluates for real, nothing is contacted.
# The second run is a copy under another name, the case that once failed at load.
if ! command -v tilt >/dev/null || ! command -v kubectl >/dev/null; then
printf ' skip tilt or kubectl is not installed\n'
else
FK="$TMP/fake-kube"; mkdir -p "$FK"
real_kubectl=$(command -v kubectl)
printf '#!/usr/bin/env bash\nfor a in "$@"; do [ "$a" = apply ] && { cat >/dev/null; exit 0; }; done\nexec %q "$@"\n' \
"$real_kubectl" > "$FK/kubectl"
chmod +x "$FK/kubectl"
parses() { # cluster-name [env...] -> the manifests Tilt would deploy, or the error
local name="$1"; shift
cat > "$FK/kubeconfig" <<EOF
apiVersion: v1
kind: Config
clusters: [{name: kind-$name, cluster: {server: "https://127.0.0.1:9"}}]
contexts: [{name: kind-$name, context: {cluster: kind-$name, user: kind-$name}}]
users: [{name: kind-$name, user: {token: selftest}}]
current-context: kind-$name
EOF
env "$@" KUBECONFIG="$FK/kubeconfig" PATH="$FK:$PATH" \
timeout 120 tilt alpha tiltfile-result --context "kind-$name" > "$FK/out.json" 2> "$FK/err" \
&& grep -o '"Name": *"[^"]*"' "$FK/out.json" | sed 's/.*"\([^"]*\)"$/\1/' | sort -u | xargs \
|| grep -m1 -iE 'error|no object' "$FK/err"
}
check "the starter overlay parses" "example-service infra uncategorized" "$(parses rig)"
check "and under another name" "example-service infra uncategorized" \
"$(parses selftest-copy CLUSTER=selftest-copy)"
check "the data overlay parses" "items-api uncategorized" "$(parses data OVERLAY=examples/data)"
fi
note "the examples are overlays that work as shipped"
# They are what a real overlay is copied from, so they must at least parse.
bad=""
for f in ../examples/*/addons/*.sh; do [ -e "$f" ] && { bash -n "$f" 2>/dev/null || bad+="$f "; }; done
check "every example addon parses" "" "$bad"
if command -v python3 >/dev/null; then
bad=""
for f in ../examples/*/dags/*.py; do
[ -e "$f" ] && { python3 -c 'import ast, sys; ast.parse(open(sys.argv[1]).read())' "$f" 2>/dev/null || bad+="$f "; }
done
check "every example DAG parses" "" "$bad"
else
printf ' skip python3 is not installed\n'
fi
printf '\n'
if [ "$rc" -eq 0 ]; then
printf '%d checks passed — rig still does what it says\n' "$passed"
else
printf 'FAILED — a decision above has drifted; read the comment next to it\n' >&2
fi
exit "$rc"

350
rig/ctrl/standalone.sh Normal file
View File

@@ -0,0 +1,350 @@
#!/usr/bin/env bash
# Generate standalone kits: rig's tools flattened into single files, one folder per profile.
# Usage: standalone.sh write|check generate (or diff) standalone/<profile>/
# standalone.sh export DIR one kit for this machine's config, no credentials, outside the repo
# Notes: docs/notes/standalone.md
set -euo pipefail
cd "$(dirname "$0")"
CTRL="$PWD"
ROOT="$(cd .. && pwd)"
OUT="$ROOT/standalone"
SELF_REL="ctrl/${0##*/}"
GENERATED_TAG="GENERATED by make standalone — do not edit"
# The contract's own functions: questions rig answers FOR this generator. They
# are never carried into a kit — load_config is replaced by the frozen one, and
# the rest mean nothing without rig's tree. The only names this file knows.
CONTRACT_FUNCS="load_config config_profiles config_snapshot config_freeze config_current_profile config_left_out"
FROZEN_OPEN="# ── configuration, frozen"
FROZEN_CLOSE="# ── end of frozen configuration"
refuse() { echo >&2; echo "standalone: refusing — $*" >&2; exit 1; }
# A clean bash with nothing from the caller's shell in it. What the kit carries
# must not depend on who ran the generator or what they had exported.
clean_bash() { env -i PATH="$PATH" HOME="$HOME" CONTRACT_FUNCS="$CONTRACT_FUNCS" bash --noprofile --norc "$@"; }
# ── 1. entry points ────────────────────────────────────────────────────────
entries() {
grep -rlE --include='*.sh' '^# rig:standalone [a-z0-9-]+ [a-z0-9-]+' . 2>/dev/null \
| sed 's|^\./||' | LC_ALL=C sort
}
marker_of() { # entry -> "kit verb"
sed -nE 's/^# rig:standalone ([a-z0-9-]+) ([a-z0-9-]+).*/\1 \2/p' "$1" | head -1
}
# ── 2. the libraries an entry point sources ────────────────────────────────
# Only the entry point's own `source` lines are read as text. Everything those
# libraries pull in is resolved by bash when they are sourced in step 3.
libs_of() { # entry -> one resolved lib path per line, relative to ctrl/
local entry="$1" dir line n path
dir=$(dirname "$entry")
while IFS=: read -r n line; do
path=$(printf '%s' "$line" | sed -E 's/^[[:space:]]*(source|\.)[[:space:]]+//; s/[[:space:]]+(#.*)?$//')
path=${path#\"}; path=${path%\"}; path=${path#\'}; path=${path%\'}
case "$path" in
*'$'*) refuse "$entry:$n sources '$path' — a path with a variable in it cannot be resolved; name the file" ;;
esac
case "$path" in
*.sh) ;;
*) refuse "$entry:$n sources '$path' directly — only libraries (.sh) may be sourced; configuration has to enter through load_config" ;;
esac
path="$dir/${path#./}"; path=${path#./}
[ -f "$path" ] || refuse "$entry:$n sources '$path', which does not exist"
printf '%s\n' "$path"
done < <(grep -nE '^[[:space:]]*(source|\.)[[:space:]]+[^=]' "$entry" || true)
}
# Into the global array `libs`. Not `mapfile < <(libs_of ...)`: a refusal inside
# a process substitution only ends that subshell, so generation would carry on
# past it and fail later with a message about something else entirely.
libs_into() {
local out
out=$(libs_of "$1") || exit 1
libs=()
[ -n "$out" ] && mapfile -t libs <<< "$out"
return 0
}
# ── 3. what the libraries define, read back from bash itself ───────────────
# The frozen config replaces load_config, and the generator's own two questions
# are useless inside a kit, so none of the three is carried.
lib_defs() { # entry lib... -> declare -p globals, then declare -f functions
local entry="$1"; shift
( cd "$(dirname "$entry")" && clean_bash -c '
skip_var() { case "$1" in CONTRACT_FUNCS|BASH*|FUNCNAME|PIPESTATUS|LINENO|RANDOM|SRANDOM|SECONDS|EPOCH*|HISTCMD|COLUMNS|LINES|PWD|OLDPWD|_|SHLVL|OPTIND|OPTERR|IFS|PS4|PATH|HOME|v|f|l|before_v|before_f) return 0 ;; esac; return 1; }
before_v=" $(compgen -v | tr "\n" " ") "
before_f=" $(compgen -A function | tr "\n" " ") "
for l in "$@"; do source "$l" || { echo "__FAIL__ sourcing $l" ; exit 1; }; done
for v in $(compgen -v); do
skip_var "$v" && continue
case "$before_v" in *" $v "*) continue ;; esac
declare -p "$v"
done
for f in $(compgen -A function); do
case "$before_f" in *" $f "*) continue ;; esac
case " skip_var $CONTRACT_FUNCS " in *" $f "*) continue ;; esac
declare -f "$f"
done
' _ "$@" ) || refuse "$entry: its libraries could not be sourced cleanly"
}
# ── 4. ask rig for profiles and resolved config ────────────────────────────
ask() { # entry lib... -- function args... -> that function's stdout
local entry="$1"; shift
local libs=() a
while [ $# -gt 0 ] && [ "$1" != -- ]; do libs+=("$1"); shift; done
shift
( cd "$(dirname "$entry")" && clean_bash -c '
n=0; for a in "$@"; do n=$((n+1)); [ "$a" = -- ] && break; done
for l in "${@:1:$((n-1))}"; do source "$l"; done
shift "$n"
declare -F "$1" >/dev/null || exit 3
"$@"
' _ "${libs[@]}" -- "$@" )
}
# ── 5. assemble one kit file ───────────────────────────────────────────────
assemble() { # entry profile out-file lib...
local entry="$1" profile="$2" dest="$3"; shift 3
local libs=("$@") calls_config=no
grep -qE '(^|[^A-Za-z0-9_])load_config([^A-Za-z0-9_]|$)' "$entry" && calls_config=yes
{
echo '#!/usr/bin/env bash'
echo "# $GENERATED_TAG"
echo "#"
echo "# $(basename "$dest") for ${KIT_LABEL:-profile '$profile'}, flattened from:"
echo "# ctrl/$entry"
local l; for l in ${libs[@]+"${libs[@]}"}; do echo "# ctrl/$l"; done
echo "# Edit those and run \`make standalone\`. Changes made here are lost, and"
echo "# \`make selftest\` fails while this file differs from what rig generates."
echo
if [ ${#libs[@]} -gt 0 ]; then
echo "# ── from the libraries ──"
lib_defs "$entry" "${libs[@]}"
echo
fi
if [ "$calls_config" = yes ]; then
local frozen
frozen=$(ask "$entry" ${libs[@]+"${libs[@]}"} -- config_freeze "${FREEZE_ARG:-$profile}") \
|| refuse "ctrl/$entry calls load_config, but its libraries do not answer config_freeze ${FREEZE_ARG:-$profile}"
echo "$FROZEN_OPEN for ${KIT_LABEL:-profile '$profile'} ──"
printf '%s\n' "$frozen"
echo "$FROZEN_CLOSE ──"
echo
fi
echo "# ── ctrl/$entry ──"
# The entry point itself, minus its shebang and marker, with each source
# line it made replaced by a note — what it sourced is already above.
awk '
NR == 1 && /^#!/ { next }
/^# rig:standalone / { next }
/^[[:space:]]*(source|\.)[[:space:]]+[^=]/ { print "# (sourced library inlined above)"; next }
{ print }
' "$entry"
} > "$dest"
chmod +x "$dest"
}
# ── 6. the kit's Makefile, from the markers ────────────────────────────────
verbs_of() { # entry -> its top-level dispatch arms
awk '/^case / { inb=1; next } /^esac/ { inb=0 } inb && match($0, /^ [a-z][a-z-]*\)/) { v=substr($0, 5, RLENGTH-5); printf "%s%s", (n++ ? "|" : ""), v }' "$1"
}
makefile() { # out-dir entry...
local dir="$1"; shift
local e kit verb target verbs targets=""
for e in "$@"; do targets+=" $(basename "$e" .sh)"; done
{
echo "# $GENERATED_TAG"
echo "#"
echo "# Shorthand for the scripts beside it; they run without it. Every target"
echo "# calls a verb its script accepts — read from that script's own dispatch."
echo
echo 'HERE := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))'
echo 'ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))'
echo 'ifneq ($(ARGS),)'
echo '$(eval $(ARGS):;@:)'
echo '.PHONY: $(ARGS)'
echo 'endif'
echo
echo '.DEFAULT_GOAL := help'
echo ".PHONY: help$targets"
echo
echo 'help: ## list targets'
printf '\t%s\n' "@grep -hE '^[a-z][a-z-]*:.*?##' \$(MAKEFILE_LIST) | sed 's/:.*##/\\t/' | expand -t16"
for e in "$@"; do
read -r kit verb <<< "$(marker_of "$e")"
target=$(basename "$e" .sh)
verbs=$(verbs_of "$e")
echo
printf '%-30s ## %s.sh [%s] (default %s)\n' "$target:" "$kit" "${verbs:-?}" "$verb"
printf '\tbash $(HERE)%s.sh $(or $(ARGS),%s)\n' "$kit" "$verb"
done
} > "$dir/Makefile"
}
# ── 7. prove a kit stands alone ────────────────────────────────────────────
verify_kit() { # dir profile entry...
local dir="$1" profile="$2"; shift 2
local e kit verb f bad smoke rc
for e in "$@"; do
read -r kit verb <<< "$(marker_of "$e")"
f="$dir/$kit.sh"
bash -n "$f" 2>/dev/null || refuse "$profile/$kit.sh does not parse: $(bash -n "$f" 2>&1 | head -1)"
# Code only: comments are free to mention anything, and the frozen block
# is data — a value that happens to hold a path is harmless unless code
# opens it, and opening it is what the smoke run below would catch.
bad=$(awk -v fz_open="$FROZEN_OPEN" -v fz_close="$FROZEN_CLOSE" '
index($0, fz_open) == 1 { fz=1; next }
index($0, fz_close) == 1 { fz=0; next }
fz || /^[[:space:]]*#/ { next }
/^[[:space:]]*(source|\.)[[:space:]]+[^=]/ { printf "%d: still sources: %s\n", NR, $0; next }
# Rig-relative only. The preceding character may not be "/", so an
# absolute system path such as /var/lib/docker is not mistaken for
# rig lib/; an explicit ./ or ../ prefix is matched on its own.
/(^|[^A-Za-z0-9_.\/])(ctrl\/|lib\/|env\.d\/)|\.\.?\/(ctrl\/|lib\/|env\.d\/)|versions\.env|(^|[^A-Za-z0-9_])\.env([^A-Za-z0-9_]|$)/ {
printf "%d: refers into rig'"'"'s tree: %s\n", NR, $0
}' "$f" | head -3)
[ -z "$bad" ] || refuse "$profile/$kit.sh does not stand alone —"$'\n'"$(printf '%s\n' "$bad" | sed 's/^/ line /')"
done
# The real test: a folder holding only this kit, and nothing else from rig.
smoke=$(mktemp -d)
cp "$dir"/* "$smoke"/
for e in "$@"; do
read -r kit verb <<< "$(marker_of "$e")"
rc=0
out=$( (cd "$smoke" && timeout 120 bash "./$kit.sh" "$verb") 2>&1 ) || rc=$?
if [ "$rc" -ne 0 ]; then
rm -rf "$smoke"
refuse "$profile/$kit.sh $verb exits $rc in an empty directory:"$'\n'"$(printf '%s\n' "$out" | tail -5 | sed 's/^/ /')"
fi
done
( cd "$smoke" && make -s help >/dev/null ) || { rm -rf "$smoke"; refuse "$profile/Makefile: make help fails"; }
rm -rf "$smoke"
}
# ── generate ───────────────────────────────────────────────────────────────
generate() { # into-dir
local into="$1" e profiles="" p kit verb libs
local -a all_entries=()
while IFS= read -r e; do all_entries+=("$e"); done < <(entries)
[ ${#all_entries[@]} -gt 0 ] || refuse "no script under ctrl/ carries a '# rig:standalone <kit> <verb>' marker"
# Profiles come from whichever entry point's libraries can answer for them.
for e in "${all_entries[@]}"; do
libs_into "$e"
profiles=$(ask "$e" ${libs[@]+"${libs[@]}"} -- config_profiles 2>/dev/null) && [ -n "$profiles" ] && break
profiles=""
done
[ -n "$profiles" ] || refuse "no entry point's libraries answer config_profiles, so there is nothing to generate a kit per"
for p in $profiles; do
mkdir -p "$into/$p"
for e in "${all_entries[@]}"; do
read -r kit verb <<< "$(marker_of "$e")"
libs_into "$e"
assemble "$e" "$p" "$into/$p/$kit.sh" ${libs[@]+"${libs[@]}"}
done
makefile "$into/$p" "${all_entries[@]}"
verify_kit "$into/$p" "$p" "${all_entries[@]}"
echo " $p: $(cd "$into/$p" && ls | tr '\n' ' ')"
done
}
# A kit folder is ours if its Makefile says so. Anything else under standalone/
# is left alone, so a hand-written file there is never swept away.
is_generated_dir() { grep -qF "$GENERATED_TAG" "$1/Makefile" 2>/dev/null; }
cmd="${1:-write}"
[ $# -gt 0 ] && shift
case "$cmd" in
write)
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
echo "generating kits from rig's current tree"
generate "$tmp"
mkdir -p "$OUT"
for d in "$OUT"/*/; do
d=${d%/}; [ -d "$d" ] || continue
if is_generated_dir "$d" && [ ! -d "$tmp/${d##*/}" ]; then
echo " removed $(basename "$d") — no such profile any more"
rm -rf "$d"
fi
done
for d in "$tmp"/*/; do
d=${d%/}
rm -rf "$OUT/${d##*/}"
cp -r "$d" "$OUT/${d##*/}"
done
echo "wrote standalone/<profile>/ — every kit verified to stand alone"
;;
check)
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
generate "$tmp" >/dev/null
stale=0
for d in "$tmp"/*/; do
d=${d%/}; p=${d##*/}
if ! diff -rq "$d" "$OUT/$p" >/dev/null 2>&1; then
echo "stale: standalone/$p$(diff -rq "$d" "$OUT/$p" 2>&1 | head -1)"
stale=1
fi
done
for d in "$OUT"/*/; do
d=${d%/}; [ -d "$d" ] || continue
if is_generated_dir "$d" && [ ! -d "$tmp/${d##*/}" ]; then
echo "stale: standalone/${d##*/} — no such profile any more"; stale=1
fi
done
[ "$stale" -eq 0 ] || { echo "run: make standalone"; exit 1; }
echo "every kit is current"
;;
export)
dest="${1:-}"
[ -n "$dest" ] || refuse "export needs a directory, outside the repo: make standalone export ~/rig-kit"
dest=$(realpath -m "$dest")
top=$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null || echo "$ROOT")
case "$dest/" in
"$top"/*) refuse "an export reflects this machine, so it does not go inside the repository — $dest is under $top. The committed per-profile kits are what standalone/ is for." ;;
esac
if [ -d "$dest" ] && [ -n "$(ls -A "$dest" 2>/dev/null)" ] && ! is_generated_dir "$dest"; then
refuse "$dest already holds something that is not a previous export — pick an empty directory"
fi
mapfile -t all_entries < <(entries)
[ ${#all_entries[@]} -gt 0 ] || refuse "no script under ctrl/ carries a '# rig:standalone <kit> <verb>' marker"
libs_into "${all_entries[0]}"
profile=$(ask "${all_entries[0]}" ${libs[@]+"${libs[@]}"} -- config_current_profile) \
|| refuse "this machine's configuration does not resolve — run make check"
left=$(ask "${all_entries[0]}" ${libs[@]+"${libs[@]}"} -- config_left_out | tr '\n' ' ')
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
echo "exporting the configuration this machine runs (profile '$profile')"
FREEZE_ARG=--current
KIT_LABEL="the configuration exported from $(hostname -s 2>/dev/null || echo this machine) (profile '$profile', local choices included, credentials not)"
for e in "${all_entries[@]}"; do
read -r kit verb <<< "$(marker_of "$e")"
libs_into "$e"
assemble "$e" "$profile" "$tmp/$kit.sh" ${libs[@]+"${libs[@]}"}
done
makefile "$tmp" "${all_entries[@]}"
verify_kit "$tmp" "export" "${all_entries[@]}"
rm -rf "$dest"; mkdir -p "$(dirname "$dest")"; cp -r "$tmp" "$dest"
echo " wrote $dest: $(cd "$dest" && ls | tr '\n' ' ')— verified to stand alone"
if [ -n "${left// /}" ]; then
echo
echo " NOT carried — this machine's own, set them on the target if it needs them:"
for k in $left; do echo " $k"; done
fi
;;
*) echo "usage: $SELF_REL [write|check|export DIR]" >&2; exit 1 ;;
esac

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

@@ -0,0 +1,57 @@
# Pinned toolchain (linux/amd64, upstream SHA256) — the manifest ctrl/deps.sh installs from.
# To bump, take the checksum from the release's own list, e.g.
# curl -sSL https://github.com/<org>/<repo>/releases/download/<tag>/checksums.txt | grep linux.x86_64
# Notes: docs/notes/versions.md
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
# ctlptl — kind cluster with a local registry wired in (keeps images off docker.io).
CTLPTL_VERSION=0.9.4
CTLPTL_SHA256=c63a1ec28e60bc3faf6becb76f53355c5cf5e0143dafdd27ad85db5584fa6b1e
CTLPTL_URL=https://github.com/tilt-dev/ctlptl/releases/download/v${CTLPTL_VERSION}/ctlptl.${CTLPTL_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
# docker compose — often missing from distro packages; deps.sh links it into
# ~/.docker/cli-plugins.
COMPOSE_VERSION=5.5.1
COMPOSE_SHA256=db1889184726840f75c4f9c001048430d4f25b3be3cb084d3ddd762bc0aed576
COMPOSE_URL=https://github.com/docker/compose/releases/download/v${COMPOSE_VERSION}/docker-compose-linux-x86_64
# Node images for KIND_VERSION, pinned by digest; K8S_VERSION picks one (default: the newest).
# Older entries are kept deliberately, for targets that run an older Kubernetes.
NODE_IMAGE_v1_36=kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5
NODE_IMAGE_v1_35=kindest/node:v1.35.5@sha256:ce977ae6d65918d0b58a5f8b5e940429c2ce42fa3a5619ec2bbc60b949c0ac95
NODE_IMAGE_v1_34=kindest/node:v1.34.8@sha256:02722c2dedddcfc00febf5d27fbeb9b7b2c14294c82109ff4a85d89ac9ba3256
NODE_IMAGE_v1_33=kindest/node:v1.33.12@sha256:3f5c8443c620245e4d355cfe09e96a91ead32ceaa569d3f1ca9edf0cb2fe2ff4
# Images pulled at runtime. Pinned by tag; the registry mode decides where they are pulled FROM.
REGISTRY_IMAGE=registry:2
# rig's own addons (ctrl/addons/<name>.sh), installed when ADDONS names them.
CERT_MANAGER_VERSION=v1.21.1
METRICS_SERVER_VERSION=v0.9.0
METALLB_VERSION=v0.16.0
# The manifests those addons apply, fetched and verified like the binaries
# (`deps.sh manifest <NAME>`), so an offline machine needs no network for them.
# Sums from the release's own asset digest; metallb publishes none, see versions.md.
CERT_MANAGER_MANIFEST_URL=https://github.com/cert-manager/cert-manager/releases/download/${CERT_MANAGER_VERSION}/cert-manager.yaml
CERT_MANAGER_MANIFEST_SHA256=5f6a499b8c1857d57f560f536e0dcc830914b45c420899fe7ad0692c8624e408
METRICS_SERVER_MANIFEST_URL=https://github.com/kubernetes-sigs/metrics-server/releases/download/${METRICS_SERVER_VERSION}/components.yaml
METRICS_SERVER_MANIFEST_SHA256=1cec29a5267809306a2c6ec74a3e449abbb705b4a8beed0c8a1963910f72c79b
METALLB_MANIFEST_URL=https://raw.githubusercontent.com/metallb/metallb/${METALLB_VERSION}/config/manifests/metallb-native.yaml
METALLB_MANIFEST_SHA256=b0b9be2802f10aa32d45308b4457d06cde0c70544712c8d0cf5511657ffd2b69
METALLB_MANIFEST_GIT_BLOB=7fbda334cc3ac0aaabdcb081af4f543feb3c2f9f

View File

@@ -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_installer {
label="Installer container (transient)"
style=dashed
color="#1e2a4a"
fontcolor="#8892a8"
installer [label="deps installer\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 -> installer [label="docker run"]
installer -> 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.
installer -> gone [style=dotted label="exits"]
gone [label="(container discarded)" fillcolor="#0a0e17" fontcolor="#4a5568" color="#1e2a4a" style="filled,dashed"]
}

View File

@@ -0,0 +1,128 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.1.2 (0)
-->
<!-- Title: rig_install Pages: 1 -->
<svg width="1145pt" height="287pt"
viewBox="0.00 0.00 1145.00 287.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 283.29)">
<title>rig_install</title>
<polygon fill="#0a0e17" stroke="none" points="-4,4 -4,-283.29 1141.06,-283.29 1141.06,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="568.53" y="-260.09" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#0066ff">Installation — the only host prerequisite is Docker</text>
<g id="clust1" class="cluster">
<title>cluster_host</title>
<polygon fill="#0a0e17" stroke="#1e2a4a" stroke-dasharray="5,2" points="898.49,-8 898.49,-190 1123.82,-190 1123.82,-8 898.49,-8"/>
<text xml:space="preserve" text-anchor="middle" x="1011.15" y="-170.8" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#8892a8">Your machine</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_installer</title>
<polygon fill="#0a0e17" stroke="#1e2a4a" stroke-dasharray="5,2" points="8,-95 8,-175 745.5,-175 745.5,-95 8,-95"/>
<text xml:space="preserve" text-anchor="middle" x="376.75" y="-155.8" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#8892a8">Installer container (transient)</text>
</g>
<!-- docker -->
<g id="node1" class="node">
<title>docker</title>
<polygon fill="#1a1a3a" stroke="#1e2a4a" points="1115.82,-31.9 1115.82,-54.1 1054.51,-69.79 967.8,-69.79 906.49,-54.1 906.49,-31.9 967.8,-16.21 1054.51,-16.21 1115.82,-31.9"/>
<text xml:space="preserve" text-anchor="middle" x="1011.15" y="-46.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#0066ff">Docker</text>
<text xml:space="preserve" text-anchor="middle" x="1011.15" y="-32.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#0066ff">(the one prerequisite)</text>
</g>
<!-- installer -->
<g id="node3" class="node">
<title>installer</title>
<polygon fill="#121829" stroke="#1e2a4a" points="181.25,-139 16,-139 16,-103 181.25,-103 181.25,-139"/>
<text xml:space="preserve" text-anchor="middle" x="98.62" y="-124.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">deps installer</text>
<text xml:space="preserve" text-anchor="middle" x="98.62" y="-110.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">curl · jq · python · graphviz</text>
</g>
<!-- docker&#45;&gt;installer -->
<g id="edge1" class="edge">
<title>docker&#45;&gt;installer</title>
<path fill="none" stroke="#4a5568" d="M906.12,-45.7C757.47,-50.51 475.98,-63.12 238.25,-94 223.38,-95.93 207.7,-98.48 192.45,-101.24"/>
<polygon fill="#4a5568" stroke="#4a5568" points="192.13,-97.74 182.93,-103.01 193.4,-104.63 192.13,-97.74"/>
<text xml:space="preserve" text-anchor="middle" x="506.62" y="-74.39" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">docker run</text>
</g>
<!-- bin -->
<g id="node2" class="node">
<title>bin</title>
<path fill="#121829" stroke="#1e2a4a" d="M1069.03,-148.28C1069.03,-151.63 1043.09,-154.34 1011.15,-154.34 979.22,-154.34 953.28,-151.63 953.28,-148.28 953.28,-148.28 953.28,-93.72 953.28,-93.72 953.28,-90.37 979.22,-87.66 1011.15,-87.66 1043.09,-87.66 1069.03,-90.37 1069.03,-93.72 1069.03,-93.72 1069.03,-148.28 1069.03,-148.28"/>
<path fill="none" stroke="#1e2a4a" d="M1069.03,-148.28C1069.03,-144.94 1043.09,-142.22 1011.15,-142.22 979.22,-142.22 953.28,-144.94 953.28,-148.28"/>
<text xml:space="preserve" text-anchor="middle" x="1011.15" y="-130.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">~/.local/bin</text>
<text xml:space="preserve" text-anchor="middle" x="1011.15" y="-117.3" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">kind · kubectl · tilt</text>
<text xml:space="preserve" text-anchor="middle" x="1011.15" y="-103.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">jq</text>
</g>
<!-- detect -->
<g id="node4" class="node">
<title>detect</title>
<polygon fill="#121829" stroke="#1e2a4a" points="429,-139 238.25,-139 238.25,-103 429,-103 429,-139"/>
<text xml:space="preserve" text-anchor="middle" x="333.62" y="-124.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">detect host</text>
<text xml:space="preserve" text-anchor="middle" x="333.62" y="-110.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">WSL · memory · inotify · docker</text>
</g>
<!-- installer&#45;&gt;detect -->
<g id="edge2" class="edge">
<title>installer&#45;&gt;detect</title>
<path fill="none" stroke="#4a5568" d="M181.44,-121C195.97,-121 211.28,-121 226.37,-121"/>
<polygon fill="#4a5568" stroke="#4a5568" points="226.33,-124.5 236.33,-121 226.33,-117.5 226.33,-124.5"/>
</g>
<!-- gone -->
<g id="node8" class="node">
<title>gone</title>
<polygon fill="#0a0e17" stroke="#1e2a4a" stroke-dasharray="5,2" points="400.5,-47 266.75,-47 266.75,-11 400.5,-11 400.5,-47"/>
<text xml:space="preserve" text-anchor="middle" x="333.62" y="-25.3" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#4a5568">(container discarded)</text>
</g>
<!-- installer&#45;&gt;gone -->
<g id="edge7" class="edge">
<title>installer&#45;&gt;gone</title>
<path fill="none" stroke="#4a5568" stroke-dasharray="1,5" d="M119.29,-102.62C138.26,-86 168.54,-62.29 199.25,-49.75 216.76,-42.6 236.49,-37.9 255.29,-34.81"/>
<polygon fill="#4a5568" stroke="#4a5568" points="255.67,-38.29 265.04,-33.35 254.64,-31.37 255.67,-38.29"/>
<text xml:space="preserve" text-anchor="middle" x="209.75" y="-52.45" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">exits</text>
</g>
<!-- fetch -->
<g id="node5" class="node">
<title>fetch</title>
<polygon fill="#121829" stroke="#1e2a4a" points="737.5,-139 584.25,-139 584.25,-103 737.5,-103 737.5,-139"/>
<text xml:space="preserve" text-anchor="middle" x="660.88" y="-124.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">fetch + verify</text>
<text xml:space="preserve" text-anchor="middle" x="660.88" y="-110.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">SHA256, pinned versions</text>
</g>
<!-- detect&#45;&gt;fetch -->
<g id="edge3" class="edge">
<title>detect&#45;&gt;fetch</title>
<path fill="none" stroke="#4a5568" d="M429.14,-121C474.43,-121 528.32,-121 572.63,-121"/>
<polygon fill="#4a5568" stroke="#4a5568" points="572.46,-124.5 582.46,-121 572.46,-117.5 572.46,-124.5"/>
</g>
<!-- report -->
<g id="node7" class="node">
<title>report</title>
<polygon fill="#3a1a1a" stroke="#1e2a4a" points="706.75,-219 615,-219 615,-183 706.75,-183 706.75,-219"/>
<text xml:space="preserve" text-anchor="middle" x="660.88" y="-204.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#ffc107">report what it</text>
<text xml:space="preserve" text-anchor="middle" x="660.88" y="-190.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#ffc107">CANNOT do</text>
</g>
<!-- detect&#45;&gt;report -->
<g id="edge6" class="edge">
<title>detect&#45;&gt;report</title>
<path fill="none" stroke="#ffc107" stroke-dasharray="5,2" d="M409.65,-139.45C468.85,-154.02 550.13,-174.01 603.78,-187.2"/>
<polygon fill="#ffc107" stroke="#ffc107" points="602.77,-190.56 613.32,-189.55 604.45,-183.76 602.77,-190.56"/>
<text xml:space="preserve" text-anchor="middle" x="506.62" y="-180.06" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">sudo / Windows&#45;side steps</text>
</g>
<!-- fetch&#45;&gt;bin -->
<g id="edge5" class="edge">
<title>fetch&#45;&gt;bin</title>
<path fill="none" stroke="#4a5568" d="M737.88,-121C798.58,-121 882.9,-121 941.56,-121"/>
<polygon fill="#4a5568" stroke="#4a5568" points="941.43,-124.5 951.43,-121 941.43,-117.5 941.43,-124.5"/>
<text xml:space="preserve" text-anchor="middle" x="811.38" y="-123.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">install</text>
</g>
<!-- upstream -->
<g id="node6" class="node">
<title>upstream</title>
<polygon fill="#1a3a1a" stroke="#1e2a4a" points="1137.06,-213.9 1137.06,-236.1 1063.3,-251.79 959,-251.79 885.25,-236.1 885.25,-213.9 959,-198.21 1063.3,-198.21 1137.06,-213.9"/>
<text xml:space="preserve" text-anchor="middle" x="1011.15" y="-228.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#00c853">upstream</text>
<text xml:space="preserve" text-anchor="middle" x="1011.15" y="-214.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#00c853">releases / corporate mirror</text>
</g>
<!-- fetch&#45;&gt;upstream -->
<g id="edge4" class="edge">
<title>fetch&#45;&gt;upstream</title>
<path fill="none" stroke="#00c853" d="M714.8,-139.5C759.85,-154.95 826.43,-177.11 885.25,-194 894.79,-196.74 904.78,-199.46 914.78,-202.09"/>
<polygon fill="#00c853" stroke="#00c853" points="913.59,-205.4 924.15,-204.52 915.35,-198.62 913.59,-205.4"/>
<text xml:space="preserve" text-anchor="middle" x="811.38" y="-191.36" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">pinned + checksummed</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@@ -0,0 +1,56 @@
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 folder — copies never collide"
labelloc=t
fontsize=16
fontcolor="#0066ff"
dirname [label="folder name\nthe overlay's, else rig's\ne.g. platform-v2/" 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\nplatform-v2" fillcolor="#121829"]
ctx [label="kubectl context\nkind-platform-v2" fillcolor="#121829"]
img [label="image tag\nplatform-v2-deps" fillcolor="#121829"]
ports [label="port block\n2043020439" fillcolor="#121829"]
reg [label="registry container\nplatform-v2-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/<profile>.env\noptional: registry · mirror" fillcolor="#121829"]
overlay [label="<overlay>/rig.env\noptional: what runs" fillcolor="#121829"]
localenv [label="ctrl/.env\nsecrets, overrides" fillcolor="#121829"]
shell [label="the environment\nOVERLAY=local/x make …" fillcolor="#1a3a1a" fontcolor="#00c853"]
}
dirname -> cname
dirname -> ctx
dirname -> img
dirname -> ports
dirname -> reg
versions -> profile [label="overridden by"]
profile -> overlay [label="overridden by"]
overlay -> 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]
}

View File

@@ -0,0 +1,184 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.1.2 (0)
-->
<!-- Title: rig_environment Pages: 1 -->
<svg width="981pt" height="585pt"
viewBox="0.00 0.00 981.00 585.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 580.74)">
<title>rig_environment</title>
<polygon fill="#0a0e17" stroke="none" points="-4,4 -4,-580.74 977,-580.74 977,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="486.5" y="-557.54" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#0066ff">One environment per folder — copies never collide</text>
<g id="clust1" class="cluster">
<title>cluster_derived</title>
<polygon fill="#0a0e17" stroke="#1e2a4a" stroke-dasharray="5,2" points="8,-65 8,-144.5 615,-144.5 615,-65 8,-65"/>
<text xml:space="preserve" text-anchor="middle" x="311.5" y="-125.3" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#8892a8">Everything below is derived from it</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_config</title>
<polygon fill="#0a0e17" stroke="#1e2a4a" stroke-dasharray="5,2" points="623,-65 623,-541.24 965,-541.24 965,-65 623,-65"/>
<text xml:space="preserve" text-anchor="middle" x="794" y="-522.04" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#8892a8">Configuration — weakest first, later wins</text>
</g>
<!-- dirname -->
<g id="node1" class="node">
<title>dirname</title>
<polygon fill="#1f6feb" stroke="#1e2a4a" points="411.98,-203.49 411.98,-234.25 346.97,-255.99 255.03,-255.99 190.02,-234.25 190.02,-203.49 255.03,-181.75 346.97,-181.75 411.98,-203.49"/>
<text xml:space="preserve" text-anchor="middle" x="301" y="-228.67" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#ffffff">folder name</text>
<text xml:space="preserve" text-anchor="middle" x="301" y="-215.17" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#ffffff">the overlay&#39;s, else rig&#39;s</text>
<text xml:space="preserve" text-anchor="middle" x="301" y="-201.67" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#ffffff">e.g. platform&#45;v2/</text>
</g>
<!-- cname -->
<g id="node2" class="node">
<title>cname</title>
<polygon fill="#121829" stroke="#1e2a4a" points="104,-109 16,-109 16,-73 104,-73 104,-109"/>
<text xml:space="preserve" text-anchor="middle" x="60" y="-94.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">cluster name</text>
<text xml:space="preserve" text-anchor="middle" x="60" y="-80.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">platform&#45;v2</text>
</g>
<!-- dirname&#45;&gt;cname -->
<g id="edge1" class="edge">
<title>dirname&#45;&gt;cname</title>
<path fill="none" stroke="#4a5568" d="M217.9,-193.66C183.86,-181.7 145.02,-165.31 113,-144.5 101.69,-137.15 90.82,-127.09 81.89,-117.75"/>
<polygon fill="#4a5568" stroke="#4a5568" points="84.65,-115.58 75.31,-110.58 79.49,-120.31 84.65,-115.58"/>
</g>
<!-- ctx -->
<g id="node3" class="node">
<title>ctx</title>
<polygon fill="#121829" stroke="#1e2a4a" points="228,-109 122,-109 122,-73 228,-73 228,-109"/>
<text xml:space="preserve" text-anchor="middle" x="175" y="-94.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">kubectl context</text>
<text xml:space="preserve" text-anchor="middle" x="175" y="-80.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">kind&#45;platform&#45;v2</text>
</g>
<!-- dirname&#45;&gt;ctx -->
<g id="edge2" class="edge">
<title>dirname&#45;&gt;ctx</title>
<path fill="none" stroke="#4a5568" d="M264.56,-181.46C244.01,-160.94 218.85,-135.8 200.43,-117.41"/>
<polygon fill="#4a5568" stroke="#4a5568" points="203.11,-115.13 193.56,-110.54 198.16,-120.08 203.11,-115.13"/>
</g>
<!-- img -->
<g id="node4" class="node">
<title>img</title>
<polygon fill="#121829" stroke="#1e2a4a" points="355.88,-109 246.12,-109 246.12,-73 355.88,-73 355.88,-109"/>
<text xml:space="preserve" text-anchor="middle" x="301" y="-94.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">image tag</text>
<text xml:space="preserve" text-anchor="middle" x="301" y="-80.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">platform&#45;v2&#45;deps</text>
</g>
<!-- dirname&#45;&gt;img -->
<g id="edge3" class="edge">
<title>dirname&#45;&gt;img</title>
<path fill="none" stroke="#4a5568" d="M301,-181.46C301,-162.23 301,-138.96 301,-120.97"/>
<polygon fill="#4a5568" stroke="#4a5568" points="304.5,-120.98 301,-110.98 297.5,-120.98 304.5,-120.98"/>
</g>
<!-- ports -->
<g id="node5" class="node">
<title>ports</title>
<polygon fill="#121829" stroke="#1e2a4a" points="462.38,-109 373.62,-109 373.62,-73 462.38,-73 462.38,-109"/>
<text xml:space="preserve" text-anchor="middle" x="418" y="-94.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">port block</text>
<text xml:space="preserve" text-anchor="middle" x="418" y="-80.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">2043020439</text>
</g>
<!-- dirname&#45;&gt;ports -->
<g id="edge4" class="edge">
<title>dirname&#45;&gt;ports</title>
<path fill="none" stroke="#4a5568" d="M334.84,-181.46C353.92,-160.94 377.28,-135.8 394.38,-117.41"/>
<polygon fill="#4a5568" stroke="#4a5568" points="396.49,-120.29 400.73,-110.58 391.36,-115.52 396.49,-120.29"/>
</g>
<!-- reg -->
<g id="node6" class="node">
<title>reg</title>
<polygon fill="#121829" stroke="#1e2a4a" points="607.12,-109 480.88,-109 480.88,-73 607.12,-73 607.12,-109"/>
<text xml:space="preserve" text-anchor="middle" x="544" y="-94.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">registry container</text>
<text xml:space="preserve" text-anchor="middle" x="544" y="-80.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">platform&#45;v2&#45;registry</text>
</g>
<!-- dirname&#45;&gt;reg -->
<g id="edge5" class="edge">
<title>dirname&#45;&gt;reg</title>
<path fill="none" stroke="#4a5568" d="M373.79,-190.51C404.52,-177.93 440.23,-161.93 471,-144.5 485.65,-136.2 500.9,-125.58 513.66,-116.06"/>
<polygon fill="#4a5568" stroke="#4a5568" points="515.41,-119.13 521.26,-110.3 511.18,-113.56 515.41,-119.13"/>
</g>
<!-- cluster -->
<g id="node12" class="node">
<title>cluster</title>
<polygon fill="#1a1a3a" stroke="#1e2a4a" points="471.81,-10.54 471.81,-25.46 440.29,-36 395.71,-36 364.19,-25.46 364.19,-10.54 395.71,0 440.29,0 471.81,-10.54"/>
<text xml:space="preserve" text-anchor="middle" x="418" y="-14.3" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#0066ff">kind cluster</text>
</g>
<!-- cname&#45;&gt;cluster -->
<g id="edge10" class="edge">
<title>cname&#45;&gt;cluster</title>
<path fill="none" stroke="#4a5568" d="M93.06,-72.61C99.54,-69.72 106.38,-67.01 113,-65 193.43,-40.54 289.9,-28.78 352.5,-23.34"/>
<polygon fill="#4a5568" stroke="#4a5568" points="352.6,-26.85 362.28,-22.53 352.02,-19.87 352.6,-26.85"/>
</g>
<!-- ports&#45;&gt;cluster -->
<g id="edge11" class="edge">
<title>ports&#45;&gt;cluster</title>
<path fill="none" stroke="#4a5568" d="M418,-72.81C418,-65.23 418,-56.1 418,-47.54"/>
<polygon fill="#4a5568" stroke="#4a5568" points="421.5,-47.54 418,-37.54 414.5,-47.54 421.5,-47.54"/>
</g>
<!-- versions -->
<g id="node7" class="node">
<title>versions</title>
<polygon fill="#121829" stroke="#1e2a4a" points="764.38,-505.74 657.62,-505.74 657.62,-469.74 764.38,-469.74 764.38,-505.74"/>
<text xml:space="preserve" text-anchor="middle" x="711" y="-490.79" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">versions.env</text>
<text xml:space="preserve" text-anchor="middle" x="711" y="-477.29" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">pinned toolchain</text>
</g>
<!-- profile -->
<g id="node8" class="node">
<title>profile</title>
<polygon fill="#121829" stroke="#1e2a4a" points="788.75,-422.49 633.25,-422.49 633.25,-386.49 788.75,-386.49 788.75,-422.49"/>
<text xml:space="preserve" text-anchor="middle" x="711" y="-407.54" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">env.d/&lt;profile&gt;.env</text>
<text xml:space="preserve" text-anchor="middle" x="711" y="-394.04" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">optional: registry · mirror</text>
</g>
<!-- versions&#45;&gt;profile -->
<g id="edge6" class="edge">
<title>versions&#45;&gt;profile</title>
<path fill="none" stroke="#4a5568" d="M711,-469.51C711,-459.24 711,-445.94 711,-434.13"/>
<polygon fill="#4a5568" stroke="#4a5568" points="714.5,-434.49 711,-424.49 707.5,-434.49 714.5,-434.49"/>
<text xml:space="preserve" text-anchor="middle" x="742.5" y="-443.19" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">overridden by</text>
</g>
<!-- overlay -->
<g id="node9" class="node">
<title>overlay</title>
<polygon fill="#121829" stroke="#1e2a4a" points="772.25,-339.24 649.75,-339.24 649.75,-303.24 772.25,-303.24 772.25,-339.24"/>
<text xml:space="preserve" text-anchor="middle" x="711" y="-324.29" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">&lt;overlay&gt;/rig.env</text>
<text xml:space="preserve" text-anchor="middle" x="711" y="-310.79" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">optional: what runs</text>
</g>
<!-- profile&#45;&gt;overlay -->
<g id="edge7" class="edge">
<title>profile&#45;&gt;overlay</title>
<path fill="none" stroke="#4a5568" d="M711,-386.26C711,-375.99 711,-362.69 711,-350.88"/>
<polygon fill="#4a5568" stroke="#4a5568" points="714.5,-351.24 711,-341.24 707.5,-351.24 714.5,-351.24"/>
<text xml:space="preserve" text-anchor="middle" x="742.5" y="-359.94" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">overridden by</text>
</g>
<!-- localenv -->
<g id="node10" class="node">
<title>localenv</title>
<polygon fill="#121829" stroke="#1e2a4a" points="768.88,-236.87 653.12,-236.87 653.12,-200.87 768.88,-200.87 768.88,-236.87"/>
<text xml:space="preserve" text-anchor="middle" x="711" y="-221.92" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">ctrl/.env</text>
<text xml:space="preserve" text-anchor="middle" x="711" y="-208.42" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">secrets, overrides</text>
</g>
<!-- overlay&#45;&gt;localenv -->
<g id="edge8" class="edge">
<title>overlay&#45;&gt;localenv</title>
<path fill="none" stroke="#4a5568" d="M711,-302.76C711,-287.79 711,-265.95 711,-248.44"/>
<polygon fill="#4a5568" stroke="#4a5568" points="714.5,-248.67 711,-238.67 707.5,-248.67 714.5,-248.67"/>
<text xml:space="preserve" text-anchor="middle" x="742.5" y="-276.69" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">overridden by</text>
</g>
<!-- shell -->
<g id="node11" class="node">
<title>shell</title>
<polygon fill="#1a3a1a" stroke="#1e2a4a" points="791,-109 631,-109 631,-73 791,-73 791,-109"/>
<text xml:space="preserve" text-anchor="middle" x="711" y="-94.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#00c853">the environment</text>
<text xml:space="preserve" text-anchor="middle" x="711" y="-80.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#00c853">OVERLAY=local/x make …</text>
</g>
<!-- localenv&#45;&gt;shell -->
<g id="edge9" class="edge">
<title>localenv&#45;&gt;shell</title>
<path fill="none" stroke="#00c853" d="M711,-200.63C711,-180.03 711,-145.27 711,-120.62"/>
<polygon fill="#00c853" stroke="#00c853" points="714.5,-120.95 711,-110.95 707.5,-120.95 714.5,-120.95"/>
<text xml:space="preserve" text-anchor="middle" x="742.5" y="-155.2" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">overridden by</text>
</g>
<!-- shell&#45;&gt;cluster -->
<g id="edge12" class="edge">
<title>shell&#45;&gt;cluster</title>
<path fill="none" stroke="#4a5568" stroke-dasharray="5,2" d="M648.24,-72.58C638.47,-69.98 628.47,-67.37 619,-65 570.38,-52.84 514.82,-40.22 474.45,-31.28"/>
<polygon fill="#4a5568" stroke="#4a5568" points="475.23,-27.87 464.71,-29.13 473.72,-34.7 475.23,-27.87"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -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"]
}

View File

@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.1.2 (0)
-->
<!-- Title: estate Pages: 1 -->
<svg width="579pt" height="362pt"
viewBox="0.00 0.00 579.00 362.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 357.62)">
<title>estate</title>
<polygon fill="#0a0e17" stroke="none" points="-4,4 -4,-357.62 575.41,-357.62 575.41,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="285.7" y="-334.42" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#ffc107">Estate topology — PLACEHOLDER</text>
<g id="clust1" class="cluster">
<title>cluster_new</title>
<polygon fill="#0a0e17" stroke="#1e2a4a" stroke-dasharray="5,2" points="8,-130.12 8,-210.12 199,-210.12 199,-130.12 8,-130.12"/>
<text xml:space="preserve" text-anchor="middle" x="103.5" y="-190.92" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#8892a8">New</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_core</title>
<polygon fill="#0a0e17" stroke="#1e2a4a" stroke-dasharray="5,2" points="357.33,-172.12 357.33,-318.12 534.83,-318.12 534.83,-172.12 357.33,-172.12"/>
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-298.92" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#8892a8">Core (mocked)</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_legacy</title>
<polygon fill="#0a0e17" stroke="#1e2a4a" stroke-dasharray="5,2" points="341.83,-84.12 341.83,-164.12 551.33,-164.12 551.33,-84.12 341.83,-84.12"/>
<text xml:space="preserve" text-anchor="middle" x="446.58" y="-144.92" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#8892a8">Legacy estate (mocked)</text>
</g>
<!-- api -->
<g id="node1" class="node">
<title>api</title>
<polygon fill="#1a3a1a" stroke="#1e2a4a" points="191,-174.12 16,-174.12 16,-138.12 191,-138.12 191,-174.12"/>
<text xml:space="preserve" text-anchor="middle" x="103.5" y="-159.17" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#00c853">service under work</text>
<text xml:space="preserve" text-anchor="middle" x="103.5" y="-145.67" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#00c853">(real: built and hot&#45;reloaded)</text>
</g>
<!-- svc_a -->
<g id="node2" class="node">
<title>svc_a</title>
<polygon fill="#121829" stroke="#1e2a4a" points="526.83,-282.12 365.33,-282.12 365.33,-246.12 526.83,-246.12 526.83,-282.12"/>
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-267.17" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">upstream service</text>
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-253.67" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">(mock: canned responses)</text>
</g>
<!-- api&#45;&gt;svc_a -->
<g id="edge1" class="edge">
<title>api&#45;&gt;svc_a</title>
<path fill="none" stroke="#4a5568" d="M149.07,-174.56C167.54,-182.07 189.23,-190.7 209,-198.12 258.25,-216.6 270.12,-222.85 320.75,-237.12 331.43,-240.13 342.71,-243 353.94,-245.67"/>
<polygon fill="#4a5568" stroke="#4a5568" points="353.12,-249.07 363.66,-247.92 354.71,-242.25 353.12,-249.07"/>
<text xml:space="preserve" text-anchor="middle" x="255.88" y="-234.24" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">HTTP</text>
</g>
<!-- db -->
<g id="node3" class="node">
<title>db</title>
<path fill="#121829" stroke="#1e2a4a" d="M480.7,-223.81C480.7,-226.22 465.18,-228.18 446.08,-228.18 426.97,-228.18 411.45,-226.22 411.45,-223.81 411.45,-223.81 411.45,-184.43 411.45,-184.43 411.45,-182.02 426.97,-180.06 446.08,-180.06 465.18,-180.06 480.7,-182.02 480.7,-184.43 480.7,-184.43 480.7,-223.81 480.7,-223.81"/>
<path fill="none" stroke="#1e2a4a" d="M480.7,-223.81C480.7,-221.39 465.18,-219.43 446.08,-219.43 426.97,-219.43 411.45,-221.39 411.45,-223.81"/>
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-207.17" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">datastore</text>
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-193.67" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">(mock)</text>
</g>
<!-- api&#45;&gt;db -->
<g id="edge2" class="edge">
<title>api&#45;&gt;db</title>
<path fill="none" stroke="#4a5568" d="M191.32,-168.36C257.83,-177.73 346.91,-190.28 399.91,-197.75"/>
<polygon fill="#4a5568" stroke="#4a5568" points="399.4,-201.22 409.79,-199.15 400.38,-194.29 399.4,-201.22"/>
<text xml:space="preserve" text-anchor="middle" x="255.88" y="-185.69" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">query</text>
</g>
<!-- batch -->
<g id="node4" class="node">
<title>batch</title>
<polygon fill="#121829" stroke="#1e2a4a" points="537.33,-128.12 354.83,-128.12 354.83,-92.12 537.33,-92.12 537.33,-128.12"/>
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-113.17" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">batch drop</text>
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-99.67" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">(mock: writes files on a timer)</text>
</g>
<!-- api&#45;&gt;batch -->
<g id="edge3" class="edge">
<title>api&#45;&gt;batch</title>
<path fill="none" stroke="#4a5568" stroke-dasharray="5,2" d="M191.32,-144.39C237.67,-138.13 294.98,-130.39 343.4,-123.85"/>
<polygon fill="#4a5568" stroke="#4a5568" points="343.61,-127.36 353.05,-122.55 342.67,-120.42 343.61,-127.36"/>
<text xml:space="preserve" text-anchor="middle" x="255.88" y="-143.94" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">file handoff</text>
</g>
<!-- remote -->
<g id="node5" class="node">
<title>remote</title>
<polygon fill="#3a1a1a" stroke="#1e2a4a" points="571.41,-21.74 571.41,-52.5 497.99,-74.24 394.17,-74.24 320.75,-52.5 320.75,-21.74 394.17,0 497.99,0 571.41,-21.74"/>
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-46.92" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#ffc107">external system</text>
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-33.42" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#ffc107">(remote: ExternalName,</text>
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-19.92" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#ffc107">reachable only from a VDI)</text>
</g>
<!-- api&#45;&gt;remote -->
<g id="edge4" class="edge">
<title>api&#45;&gt;remote</title>
<path fill="none" stroke="#ffc107" stroke-dasharray="5,2" d="M149.55,-137.68C167.9,-130.34 189.37,-121.97 209,-114.87 254.45,-98.43 305.32,-81.49 348.17,-67.64"/>
<polygon fill="#ffc107" stroke="#ffc107" points="349,-71.05 357.44,-64.65 346.85,-64.39 349,-71.05"/>
<text xml:space="preserve" text-anchor="middle" x="255.88" y="-117.57" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">only when reachable</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.9 KiB

607
rig/docs/index.html Normal file
View File

@@ -0,0 +1,607 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>rig — local environment installer</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0e17;
color: #e8eaf0;
font-family: 'Inter', sans-serif;
line-height: 1.6;
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
header {
padding: 16px 24px;
border-bottom: 1px solid #1e2a4a;
display: flex;
align-items: baseline;
gap: 16px;
flex-shrink: 0;
}
header h1 {
font-family: 'JetBrains Mono', monospace;
font-size: 22px;
font-weight: 600;
letter-spacing: 3px;
color: #0066ff;
}
header .subtitle {
font-size: 13px;
color: #4a5568;
letter-spacing: 1px;
text-transform: uppercase;
}
.layout { display: flex; flex: 1; min-height: 0; }
nav {
display: flex;
flex-direction: column;
width: 200px;
flex-shrink: 0;
background: #121829;
border-right: 1px solid #1e2a4a;
padding: 8px 0;
overflow-y: auto;
}
nav a {
padding: 10px 20px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: #8892a8;
text-decoration: none;
border-left: 2px solid transparent;
transition: all 0.15s;
cursor: pointer;
}
nav a:hover { color: #e8eaf0; background: #1a2340; }
nav a.active { color: #0066ff; border-left-color: #0066ff; background: #0d1a33; }
main { flex: 1; overflow: auto; padding: 32px 48px; }
.section { display: none; animation: fadeIn 0.2s ease; }
.section.active { display: block; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.section h2 {
font-family: 'JetBrains Mono', monospace;
font-size: 15px;
font-weight: 500;
color: #8892a8;
margin-bottom: 8px;
letter-spacing: 1px;
text-transform: uppercase;
}
.section > p.lede {
font-size: 13px;
color: #4a5568;
margin-bottom: 24px;
max-width: 800px;
}
.prose { max-width: 820px; }
.prose p { font-size: 14px; color: #b4bccf; line-height: 1.7; margin-bottom: 14px; }
.prose p b { color: #e8eaf0; }
.prose ul { margin: 0 0 16px 20px; }
.prose li { font-size: 14px; color: #b4bccf; margin-bottom: 6px; }
.prose h3 {
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
text-transform: uppercase;
color: #e8eaf0;
margin: 32px 0 10px;
letter-spacing: 1px;
}
.prose code, pre code {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: #7ab0ff;
background: #121829;
padding: 1px 5px;
border-radius: 3px;
}
pre {
background: #121829;
border: 1px solid #1e2a4a;
padding: 16px;
overflow: auto;
margin-bottom: 16px;
}
pre code { background: none; padding: 0; }
pre .c { color: #4a5568; }
pre .k { color: #0066ff; }
.graph-container { margin: 16px 0; }
.graph-container img {
display: block;
max-width: 100%;
background: #0a0e17;
border: 1px solid #1e2a4a;
padding: 12px;
}
dl {
display: grid;
grid-template-columns: max-content 1fr;
gap: 10px 24px;
margin: 16px 0;
max-width: 820px;
}
dt {
font-family: 'JetBrains Mono', monospace;
color: #0066ff;
font-size: 13px;
padding-top: 2px;
}
dd { font-size: 14px; color: #b4bccf; line-height: 1.6; }
table { border-collapse: collapse; margin: 16px 0; max-width: 820px; }
th, td {
text-align: left;
padding: 7px 16px 7px 0;
font-size: 13px;
border-bottom: 1px solid #1e2a4a;
color: #b4bccf;
}
th {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
text-transform: uppercase;
color: #8892a8;
letter-spacing: 1px;
}
td code { white-space: nowrap; }
.note {
border-left: 2px solid #ffc107;
background: #17130a;
padding: 12px 16px;
margin: 16px 0;
max-width: 820px;
}
.note p { margin: 0; font-size: 13px; color: #b4bccf; }
.note b { color: #ffc107; }
.menu-toggle {
display: none;
background: transparent;
border: 1px solid #1e2a4a;
color: #8892a8;
padding: 6px 10px;
font-size: 14px;
cursor: pointer;
line-height: 1;
margin-left: auto;
}
.menu-toggle:hover { background: #1a2340; }
.nav-backdrop {
display: none;
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 10;
}
.layout.nav-open .nav-backdrop { display: block; }
@media (max-width: 720px) {
header { padding: 10px 12px; gap: 8px; }
header h1 { font-size: 16px; letter-spacing: 1px; }
header .subtitle { display: none; }
.menu-toggle { display: inline-block; }
.layout { position: relative; }
nav {
position: absolute; left: 0; top: 0; bottom: 0;
width: 200px; z-index: 20;
transform: translateX(-100%);
transition: transform 0.2s ease;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.5);
}
.layout.nav-open nav { transform: translateX(0); }
main { padding: 16px; }
.section h2 { font-size: 13px; }
.prose p, .prose li { font-size: 13px; }
}
</style>
</head>
<body>
<header>
<h1>RIG</h1>
<span class="subtitle">local environment installer</span>
<button class="menu-toggle">&#9776;</button>
</header>
<div class="layout">
<div class="nav-backdrop"></div>
<nav>
<a href="#start">Start here</a>
<a href="#steps">The steps</a>
<a href="#install">Installation</a>
<a href="#environments">Environments</a>
<a href="#overlays">Overlays</a>
<a href="#profiles">Profiles</a>
<a href="#registry">Registry</a>
<a href="#architecture">Architecture</a>
<a href="#troubleshooting">Troubleshooting</a>
</nav>
<main>
<section class="section" id="start">
<h2>Start here</h2>
<p class="lede">A runnable local model of a large, regulated estate — legacy and new side by side.</p>
<div class="prose">
<p>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 <b>onboarding and exploration</b>, not a production replica.</p>
<p>Most services in it are deliberately <b>not real</b>. 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&nbsp;GB, but mocks are about 30&nbsp;MB each, so three faithful copies do.</p>
<h3>The only prerequisite</h3>
<p><b>Docker.</b> No curl, no jq, no python, no apt repositories to configure.</p>
<pre><code><span class="c"># then, in the environment directory:</span>
make check <span class="c"># is this machine ready? reports, never fixes</span>
make deps <span class="c"># install the pinned toolchain</span>
make cluster up <span class="c"># cluster + registry + addons; ports derive by themselves</span>
</code></pre>
<p>Read <code>make check</code> before <code>make deps</code>. It never changes
anything — it prints what it found and, at the end, the steps it cannot perform
for you.</p>
</div>
</section>
<section class="section" id="steps">
<h2>The steps</h2>
<p class="lede">Start to finish, in order, with what each one actually does.</p>
<div class="prose">
<h3>1 &middot; make check</h3>
<p>Asks whether this machine is ready. It <b>changes nothing</b> — it
reports what it found and, at the end, the things only a human can do
(anything needing <code>sudo</code>, or a Windows-side restart). Read it
before installing anything; it is faster than discovering the same problems
one failure at a time.</p>
<pre><code>make check</code></pre>
<h3>2 &middot; make deps</h3>
<p>Installs the pinned toolchain — only what is missing — and tells you
if its directory is not on PATH yet. Running it twice is safe.</p>
<pre><code>make deps</code></pre>
<h3>3 &middot; make cluster up</h3>
<p>Builds the cluster, starts its registry and installs the profile's
addons — there is nothing else to run first. It prints what the profile
locks in <i>before</i> spending the time, because the kind config is
fixed at creation and cannot be changed afterwards.</p>
<p>Re-running is safe and, more importantly, <b>convergent</b>: 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.</p>
<pre><code>make cluster up <span class="c"># built-in defaults — no profile needed</span>
make cluster up PROFILE=mirror <span class="c"># after copying env.d/mirror.env.example: cached registry</span>
make cluster up OVERLAY=examples/data <span class="c"># an overlay: what runs, kept outside rig</span>
make cluster reset <span class="c"># destroy and rebuild — how an edited kind config takes effect</span>
</code></pre>
<h3>4 &middot; make docs</h3>
<p>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.</p>
<pre><code>make docs</code></pre>
<h3>Checking on things</h3>
<dl>
<dt>make cluster list</dt><dd>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; <code>make cluster free</code> frees them without deleting.</dd>
<dt>make check</dt><dd>Short: host, toolchain, and whether this cluster fits, its ports, registry and addons. Details only appear when something needs attention; <code>make check all</code> prints every one.</dd>
<dt>make check mem</dt><dd>Memory in depth: what caps it, how far it really climbs, and on WSL the <code>.wslconfig</code> backup and restore.</dd>
</dl>
<h3>Running more than one</h3>
<p>Name another overlay, or copy the directory and rename it. Cluster name,
context, image tags and the port block all follow the folder name — the
overlay's, or rig's — so the second environment collides with nothing and
neither one's teardown can reach the other.</p>
<pre><code>OVERLAY=local/platform-v2 make cluster up
cp -r rig ../platform-v3 &amp;&amp; cd ../platform-v3 &amp;&amp; make cluster up
</code></pre>
</div>
</section>
<section class="section" id="install">
<h2>Installation</h2>
<p class="lede">A container installs onto the host and then gets out of the way.</p>
<div class="graph-container">
<a href="viewer.html?src=graphs/01-install.svg"><img src="graphs/01-install.svg" alt="Installation flow"></a>
</div>
<div class="prose">
<p>The installer is a container, not a shell script, for a specific reason: a
stock slim Debian has no <code>curl</code>, no <code>wget</code>, no
<code>jq</code>, no <code>python3</code> and <b>no CA bundle</b>. 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.</p>
<p>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.</p>
<h3>Pinned and verified</h3>
<p>Every tool is a single binary fetched at a pinned version and checked
against a published SHA256. Node images are pinned <b>by digest</b>, so
upgrading kind cannot silently move your Kubernetes version.</p>
<h3>Not every machine should get cluster tooling</h3>
<p>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:</p>
<table>
<tr><th>tier</th><th>installs</th><th>for</th></tr>
<tr><td><code>core</code></td><td>kubectl, jq</td><td>talk to a cluster someone else runs</td></tr>
<tr><td><code>dev</code></td><td>+ kind, tilt</td><td>build clusters and hot-reload into them</td></tr>
</table>
<pre><code>make deps core <span class="c"># kubectl and jq only — nothing that creates a cluster</span>
make deps <span class="c"># dev, the default</span>
</code></pre>
<p>Testing <i>in situ</i> on a managed machine is still possible — install
the <code>dev</code> tier deliberately when you need it. The point is that
it should be a decision rather than a side effect of installing.</p>
<p>The documentation itself needs neither tier: <code>make docs</code>
wants only Docker.</p>
<h3>Air-gapped</h3>
<pre><code>make deps image full <span class="c"># bakes every binary into the image</span>
docker save …-deps:full | gzip &gt; rig.tgz
<span class="c"># carry that one file in, then:</span>
docker load &lt; rig.tgz &amp;&amp; make cluster up PROFILE=offline <span class="c"># from env.d/offline.env.example</span>
</code></pre>
</div>
</section>
<section class="section" id="environments">
<h2>Environments</h2>
<p class="lede">One folder is one environment — an overlay's, or rig's own. Copy it, rename it, run it.</p>
<div class="graph-container">
<a href="viewer.html?src=graphs/02-environment.svg"><img src="graphs/02-environment.svg" alt="Environment derivation"></a>
</div>
<div class="prose">
<p>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 folder name (the overlay's when one is named):</p>
<dl>
<dt>cluster + context</dt><dd><code>platform-v2/</code> builds <code>platform-v2</code> on <code>kind-platform-v2</code>.</dd>
<dt>port block</dt><dd>Ten ports from a hash of the name, in the 20000+ range — clear of 80, 443, 3000, 5432, 8000 and 8080.</dd>
<dt>registry + images</dt><dd>Named after the environment, so two copies never share one.</dd>
</dl>
<p>Two copies therefore never collide, and neither one's
<code>make cluster down</code> can touch the other. <code>make check</code>
shows the block; <code>bash ctrl/ports.sh persist</code> freezes it into
<code>ctrl/.env</code> if you want it fixed rather than derived.</p>
<h3>Configuration layers</h3>
<p>Weakest first, later wins: built-in defaults → pinned versions → a
profile, if you name one → the overlay's <code>rig.env</code>
<code>ctrl/.env</code> → the environment. So
<code>make cluster up PROFILE=&lt;name&gt;</code> always beats every file.</p>
</div>
</section>
<section class="section" id="overlays">
<h2>Overlays</h2>
<p class="lede">What runs lives outside rig — rig reads it and never writes into it.</p>
<div class="prose">
<p>An overlay is one folder, kept outside rig's version control, holding a
use case. Every piece is optional:</p>
<table>
<tr><th>in the overlay</th><th>what rig does with it</th></tr>
<tr><td><code>rig.env</code></td><td>a config layer: addons, namespaces, images — anything a profile could set</td></tr>
<tr><td><code>k8s/overlays/dev/</code></td><td>the manifests the dev loop applies</td></tr>
<tr><td><code>kind-config.yaml.tpl</code></td><td>the cluster's shape, when it needs its own (mounts, ports)</td></tr>
<tr><td><code>addons/&lt;name&gt;.sh</code></td><td>addons, found before rig's own</td></tr>
<tr><td><code>Tiltfile</code></td><td>the workload's half of the dev loop, included by rig's</td></tr>
</table>
<pre><code>cp -r examples/starter local/myenv <span class="c"># local/ is gitignored</span>
OVERLAY=local/myenv make cluster up
</code></pre>
<p>With none named, rig runs its own <code>examples/starter</code>.
<code>examples/data</code> carries postgres, redis and airflow as an
overlay's own addons. A project can also carry rig at
<code>&lt;project&gt;/rig/</code> and be the overlay itself, with a
three-line forwarding Makefile — see <code>docs/notes/overlay.md</code>.</p>
</div>
</section>
<section class="section" id="profiles">
<h2>Profiles</h2>
<p class="lede">How this machine reaches the world — optional; rig needs none.</p>
<div class="prose">
<table>
<tr><th>example</th><th>registry</th><th>for</th></tr>
<tr><td><i>none</i></td><td>local</td><td>the built-in defaults; no profile needed</td></tr>
<tr><td><code>mirror.env.example</code></td><td>mirror</td><td>images through an internal registry</td></tr>
<tr><td><code>offline.env.example</code></td><td>local</td><td>air-gapped</td></tr>
</table>
<div class="note"><p><b>The kind config cannot be re-applied.</b> Edit
<code>ctrl/k8s/kind-config.yaml.tpl</code> (or the overlay's own); it takes effect when the cluster is created. <code>cluster up</code> prints what it
locks in before spending the time, and <code>make cluster reset</code> is
the way out.</p></div>
<h3>LoadBalancer services</h3>
<p>Real manifests use <code>type: LoadBalancer</code>, because a real
cluster has one. On a bare local cluster those Services sit at
<code>EXTERNAL-IP &lt;pending&gt;</code> forever, with no error anywhere —
the deployment looks healthy and simply is not reachable.</p>
<p>The <code>metallb</code> 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.</p>
<div class="note"><p><b>Where those addresses are reachable from.</b> 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.</p></div>
<h3>Networking</h3>
<p>The cluster uses kind's built-in networking, which <b>does</b> 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.</p>
<p>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.</p>
<h3>Memory</h3>
<p>Every cluster is a running container tree whether you are using it or not.
<code>make cluster list</code> shows what exists and what it costs;
<code>make cluster free</code> stops the others without deleting them.</p>
</div>
</section>
<section class="section" id="registry">
<h2>Registry</h2>
<p class="lede">Local, cached, or straight to the corporate registry.</p>
<div class="prose">
<table>
<tr><th>mode</th><th>what it does</th></tr>
<tr><td><code>none</code></td><td>images are built straight into the node</td></tr>
<tr><td><code>local</code></td><td>a registry container wired into the cluster</td></tr>
<tr><td><code>mirror</code></td><td>that container as a <b>pull-through cache</b> of the corporate registry</td></tr>
<tr><td><code>remote</code></td><td>no local container; pull direct with an imagePullSecret</td></tr>
</table>
<p><code>mirror</code> 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.</p>
<div class="note"><p><b>The corporate CA will bite you.</b> A corporate
registry is usually behind an internal CA, and trust has to reach
<b>three</b> places: the host Docker daemon, every cluster node's containerd
(nodes do <i>not</i> inherit host trust), and any in-cluster client. Set
<code>REGISTRY_CA_FILE</code> and <code>make check</code> reports which is
still missing. The symptom otherwise is an opaque
<code>x509: certificate signed by unknown authority</code>.</p></div>
<p>Reachability also depends on where you are: if the registry is only
routable from a managed workspace, <code>mirror</code> and <code>remote</code>
will not resolve from a laptop at all. That is what <code>local</code> and
<code>offline</code> are for.</p>
</div>
</section>
<section class="section" id="architecture">
<h2>Architecture</h2>
<p class="lede">The estate being modelled.</p>
<div class="note"><p><b>TODO — placeholder.</b> 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 <code>docs/graphs/03-architecture.dot</code> with the
extracted platform diagrams, then run <code>make docs graphs</code>.</p></div>
<div class="graph-container">
<a href="viewer.html?src=graphs/03-architecture.svg"><img src="graphs/03-architecture.svg" alt="Estate topology (placeholder)"></a>
</div>
<div class="prose">
<p>Each component is one of three things, and switching between them should be
a one-line change rather than a rewrite:</p>
<dl>
<dt>real</dt><dd>Built from source and hot-reloaded. The thing you are actually working on — usually exactly one.</dd>
<dt>mock</dt><dd>A generic stub with canned responses. Everything you do not care about today.</dd>
<dt>remote</dt><dd>No pod at all: a Service of type ExternalName pointing at the real system. In-cluster DNS resolves identically, so callers never change.</dd>
</dl>
<p>The intended end state is that this diagram is <b>generated from the
running cluster</b> rather than drawn by hand — so it becomes a report of
what exists instead of a picture of what was once intended.</p>
</div>
</section>
<section class="section" id="troubleshooting">
<h2>Troubleshooting</h2>
<p class="lede">The failures that are hard to diagnose from their symptoms.</p>
<div class="prose">
<h3>Tilt stops noticing file changes</h3>
<p>Almost always <code>inotify</code> limits, and it fails <i>silently</i>
nothing errors, changes just stop being picked up. Defaults on WSL are far too
low. <code>make check</code> reports it and prints the fix.</p>
<h3>Cluster creation dies halfway with a port error</h3>
<p>Docker reports <code>failed to bind host port … address already in use</code>
partway through creating the cluster. Run <code>make check</code> first — it
checks every port in this environment's block before anything is built.</p>
<h3>Every node stays NotReady</h3>
<p>Usually a cluster created with the default CNI disabled but the real CNI
never installed — typically an interrupted first run. Just run
<code>make cluster up</code> again: it converges rather than exiting early, and
will finish the missing steps.</p>
<h3>x509: certificate signed by unknown authority</h3>
<p>Corporate CA trust has not reached one of the three places it needs to be.
See <a href="#registry">Registry</a>.</p>
<h3>kubectl says the context does not exist</h3>
<p>The cluster can exist while its context does not — a reset or a switched
<code>KUBECONFIG</code> loses it. <code>make cluster up</code> detects this and
re-exports the context.</p>
</div>
</section>
</main>
</div>
<script>
(function () {
var layout = document.querySelector('.layout');
var main = document.querySelector('main');
function syncActive() {
var hash = location.hash.slice(1) || 'start';
document.querySelectorAll('.section').forEach(function (s) { s.classList.remove('active'); });
document.querySelectorAll('nav a').forEach(function (a) { a.classList.remove('active'); });
var section = document.getElementById(hash);
if (section) section.classList.add('active');
var link = document.querySelector('nav a[href="#' + hash + '"]');
if (link) link.classList.add('active');
if (main) main.scrollTop = 0;
layout.classList.remove('nav-open');
}
window.addEventListener('hashchange', syncActive);
window.addEventListener('DOMContentLoaded', syncActive);
syncActive();
document.addEventListener('click', function (e) {
if (e.target.closest('.menu-toggle') || e.target.closest('.nav-backdrop')) {
layout.classList.toggle('nav-open');
}
});
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,34 @@
# ctrl/Dockerfile.deps
## Purpose
The toolchain installer image. 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. It carries its own toolchain, so the only host prerequisite is Docker.
## Variants
Two variants from one file:
```
docker build -f ctrl/Dockerfile.deps --target deps -t <slug>-deps .
docker build -f ctrl/Dockerfile.deps --target deps-full -t <slug>-deps:full .
```
`deps-full` bakes every pinned binary in at build time, and the manifests rig's own addons apply (metallb, cert-manager, metrics-server). `docker save` it and you have the whole installer as one file to carry into an air-gapped network. There, put the manifests where the addons look for them:
```
docker run --rm -v "$PWD/vendor:/out/vendor" rig-deps:full manifests --to /out/vendor/manifests
```
Each is verified against its pin on the way out, and again when an addon uses it. The addons' container images still have to be preloaded into the local registry: the manifests reference quay.io and registry.k8s.io, and registry mirroring covers docker.io only.
## Packages
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.
## The installer is the standalone kit
The installer is the generated standalone kit, not deps.sh plus the files it reads. A kit is one file with its pins frozen in and is proven to run with nothing else from rig present — which is exactly what an image needs, and `make standalone` keeps it current. Pins are the same in every profile's kit.

View File

@@ -0,0 +1,45 @@
# examples/starter/Dockerfile.example
## Naming
EXAMPLE — a component image. Copy, rename, replace. Named like the manifest it feeds and the resource it becomes:
```
Dockerfile.api -> image <cluster>-api -> image: in k8s/base/api.yaml
```
That image string is the ONLY thing connecting the three. Nothing checks it; a typo shows up as a pod stuck in ImagePullBackOff pulling from the public index, which reads like a network problem and is not one.
## COPY paths are relative to the build context
The overlay's Tiltfile runs from the overlay's own folder (rig's ctrl/Tiltfile includes it), so both paths in `docker_build` are relative to the overlay:
```
context='.' the overlay folder (or a subfolder, e.g. 'repodir/api')
dockerfile='Dockerfile.api' relative to the overlay's Tiltfile too
```
Every COPY is resolved against the context, NOT against the Dockerfile's directory. With `context='.'` and the Dockerfile in a subfolder, a file sitting right beside it is still reached through that subfolder:
```
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf # correct, Dockerfile in docker/
COPY nginx.conf /etc/nginx/conf.d/default.conf # fails — no such file in the context
```
Nothing warns you. The build just cannot find a file that is visibly there.
Before overlays, rig's own ctrl/Tiltfile built with `context='..'` (the repository root) and the Dockerfile in ctrl/, which is the same trap one level up.
## Dependency layer
Dependencies first, in their own layer: they change far less often than the code, so a source edit does not reinstall them on every rebuild.
## live_update
The sync in the Tiltfile's `docker_build` must land where this image expects it:
```
live_update=[sync('api', '/app/api')]
```
matches `COPY api/ ./api/` with `WORKDIR /app`. If the two disagree, Tilt syncs into a path nothing reads and the container keeps serving the built copy — edits appear to do nothing, with no error anywhere.

View File

@@ -0,0 +1,58 @@
# Makefile
## Shape and config layers
Thin control Makefile: few targets, and the subcommand is an argument rather than a second target: `make cluster down`, not `make cluster-down`.
```
make check is this machine ready? (never changes anything)
make deps install the toolchain
make cluster up cluster + registry + addons (ports derive by themselves)
make tilt / docs work on it, read about it
```
The logic lives in the scripts, never here: `make cluster up` -> ctrl/cluster.sh up.
Config layers, weakest first: built-in defaults < ctrl/versions.env (pinned toolchain) < ctrl/env.d/<profile>.env (optional) < <overlay>/rig.env (optional) < ctrl/.env (local, gitignored) < the environment. So `make cluster up PROFILE=<name>` beats them all. See [config.md](config.md) and [overlay.md](overlay.md).
Start with: `make check && make deps && make cluster up`
## FACTS
Identity follows the FOLDER NAME the overlay's when one is named, else this directory's so either 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 folder.
Asked once, of ctrl/ports.sh, which resolves it through lib/config.sh:
```
CLUSTER KUBECONTEXT HTTP HTTPS TILT REGISTRY MANIFESTS_DIR OVERLAY_DIR
```
Read positionally, so the order is a contract; ctrl/selftest.sh pins it. The two paths are absolute, or `-` when there is none, so the count never shifts.
`OVERLAY` and `CLUSTER` given as make arguments (`make tilt OVERLAY=local/x`) are handed to that `$(shell ...)` explicitly. Before make 4.4, `$(shell)` runs with make's own environment and does not see command-line variables, while the recipes do: tilt would then be told one context and the Tiltfile would guard on another. An overlay's forwarder avoids the question by putting `OVERLAY` in the environment.
This used to be sed over ctrl/.env plus a slug computed in the Makefile, which is a SECOND derivation of values lib/config.sh already owns, and the two could disagree about the port after `ports.sh persist`, or about the name for any directory whose sanitised form differs from its raw one. One source now; the Tiltfile reads the same line.
## CLUSTER / KCTX fallback
The fallback matters: ports.sh sources config.sh, and if a profile or .env is broken it exits non-zero. Losing the cluster name would send --context to the wrong place, so fall back to the folder rather than to empty.
## ARGS as .PHONY
Words after the target become the script's subcommand; each gets a no-op rule so make does not treat them as goals. They are also marked 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 is not enough on its own; only .PHONY stops make consulting the filesystem.
## tilt: --port guard
--port is only passed when TILT_PORT resolved. It normally does, since FACTS asks ports.sh, but ports.sh can fail on a broken profile, and without the guard tilt receives a bare `--port` with no value and fails on the flag rather than on anything real. Tilt's own default is 10350, which is the number every project on this machine is trying not to collide on, so falling back to it silently is worse than not passing the flag.
The Tiltfile asks ports.sh for the rest itself (cluster, registry and where the manifests are), so nothing needs passing here beyond what tilt's own flags require.
## Aliases (kind-up, tilt-up, ...)
Aliases, not a second implementation: each one calls the same script the canonical target does.
The header argues for `make cluster down` over `make cluster-down`, and that still holds *within* the Makefile. 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.
Nothing outside the Makefile reads these names: the script is `ctrl/cluster.sh` and it takes the verb. So rename them, delete the ones you never type, or add the spelling your own projects use. An alias is two lines, and adding one costs nothing but a line in .PHONY.

View File

@@ -0,0 +1,43 @@
# ctrl/Tiltfile
## Purpose and ownership
rig's half of the dev loop, and rig's file: who we are, the context guard, the registry, the overlay's manifests and the namespaces they use. The workload's half — images, resource names and order, port-forwards — is the overlay's own `Tiltfile`, which this one includes at the end (see [overlay.md](overlay.md)). With no overlay named that is `examples/starter/Tiltfile`, so `make tilt` on a fresh clone comes up with the two examples running and nothing to edit.
Splitting it is what lets rig be replaced as a whole (✖ S1 in STALE.md).
## Nothing hardcoded to this directory
Nothing in the Tiltfile is hardcoded to this directory, deliberately. Every other project here writes its slug into the Tiltfile five or six times by hand, so a copy of the project deploys into the original's cluster until someone remembers to edit all of them. A rig is meant to be copied and renamed, and an overlay moved, so it asks instead.
## Who we are, and on which ports
One question to rig, answered by ctrl/ports.sh, which resolves it through lib/config.sh — the same path every other rig script takes. That is the point: the cluster name is NOT the bare directory name (it is lowercased and reduced to a DNS label), and the ports honour anything pinned in ctrl/.env. Recomputing either of those here in Starlark is how two copies end up disagreeing about which cluster they are talking to.
The manifests and the overlay arrive as absolute paths, or `-` when there is none.
## Refuse to deploy into the wrong cluster
Tilt snapshots the kubectl context at startup, BEFORE parsing this file, so it cannot be switched from here — only refused. `make tilt` passes --context for you; the guard catches a bare `tilt up` after some other project moved the global context.
## Images go to this environment's own registry
Fail closed. Tilt can usually infer the kind registry on its own, but "usually" is an inference, and when it misses, an unqualified name like `app` quietly means docker.io/library/app — a push to the public index instead of the registry two lines away. rig runs that registry; name it.
## Namespaces
Every namespace the manifests use has to exist before anything lands in it, and kustomize does not guarantee ordering across resources, so the Tiltfile creates them first (idempotent). The Namespaces the manifests declare are grouped as the `infra` resource, whatever they are called.
Nothing here assumes a namespace is named after the cluster (✖ S4).
## Handing over to the overlay
The facts are published as environment variables (`os.putenv`) and the overlay's Tiltfile is `include()`d. An included Tiltfile runs from its own folder: `os.getcwd()`, `local()` and every relative path in it resolve from the overlay, so it needs no path back into rig and reads the facts with `os.getenv`:
```
RIG_CLUSTER RIG_CONTEXT RIG_HTTP_PORT RIG_HTTPS_PORT RIG_TILT_PORT RIG_REGISTRY RIG_OVERLAY_DIR
```
## Catalogue
The blocks that recur across projects moved with the workload's half: `examples/starter/Tiltfile` carries them, commented, with the parts that are easy to get wrong explained next to them — building an image, a shared base built once, naming and ordering resources, reloading a gateway on a config change, kustomize flags, and reaching a service directly.

43
rig/docs/notes/addons.md Normal file
View File

@@ -0,0 +1,43 @@
# ctrl/addons.sh and ctrl/addons/*.sh
## addons.sh
Each addon is its own idempotent script — adding one is adding a file, not editing a dispatcher. `ADDONS` names them, in install order; the overlay's `addons/<name>.sh` is found before rig's `ctrl/addons/<name>.sh`, and every one runs from rig's `ctrl/` with `RIG_CTRL` exported, wherever its file lives (see [overlay.md](overlay.md)).
## What rig ships, and what it does not
rig's own addons make the *cluster* work, and are useless outside one: metallb, cert-manager, metrics-server. Things a workload happens to need — a database, a cache, a scheduler — are the workload's, and which workload needs which is not rig's business, so they live with the overlay. `examples/data/addons/` has postgres, redis and airflow as a worked example; an overlay that wants them copies them in.
## cert-manager.sh
In a regulated estate almost everything is TLS, so the interesting question
during onboarding is "does this service present a cert my client trusts" — not
"can I reach a public ACME server". A local CA answers that offline, which is
also what makes the air-gapped profile usable.
## metallb.sh — why it matters
Real manifests use LoadBalancer, because a real cluster has one. On a bare kind
cluster those Services sit at `EXTERNAL-IP <pending>` forever with no error
anywhere — the deployment looks fine and simply is not reachable. Without MetalLB,
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.
## metallb.sh — waiting for the controller
`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.
## metrics-server.sh
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.

50
rig/docs/notes/check.md Normal file
View File

@@ -0,0 +1,50 @@
# ctrl/check.sh
## Purpose
Readiness check: is this machine ready to run rig?
It reports and instructs; it never silently fixes anything. Everything it finds is either already fine, or something a human has to decide on.
Runs ctrl/deps.sh 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.
## memory
A profile on a box that is already full is the most common first failure, and it presents as pods stuck Pending rather than anything that says "memory". The check warns; it never blocks. Whether to try anyway is the user's call.
## mb_of
MEMINFO and OVERCOMMIT_FILE exist only so the tight and does-not-fit branches can be exercised against another machine's real numbers; in normal use they are the kernel's own files.
## NODE_MB
NODE_MB (what one node costs) comes from load_config (lib/config.sh), where its measurement is recorded. It lives there, not here, because the memory tool and every standalone kit need the same number: a copy of it is how rigmini.sh came to say 2 GB per node long after rig had measured 800 MB.
## container_mb
Every running container's working set in MB, tagged with the kind cluster it belongs to ('-' when it is not kind). docker stats reports usage minus page cache, which is what actually competes: cache is handed back under pressure. Counting only kind would hide the usual culprit on a managed workspace, where the memory is held by other containers entirely.
## ours_mb / still_mb
Once this environment's own cluster is running, its real footprint is already out of MemAvailable and the per-node estimate stops being relevant. Subtracting the measurement from the estimate would count the same memory twice, and a running cluster that happens to sit under 800 MB would still "need" the gap.
## ports: our own cluster
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.
The ports are extracted 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.
## Compact by default
`make check` prints one line per question — host, toolchain, and for this rig: cluster, memory,
ports, registry, addons — and adds detail only where something needs attention (`!` lines, the
"held elsewhere" list when memory is tight, the clashing port). `make check all` prints every fact,
as the full report did before 2026-09-17. `deps.sh detect all` is the same switch for the host part,
so the standalone `rigdeps.sh detect` is short too. Changed because the long report buried the few
lines that mattered.
## overlay and kind config
The rig block names the overlay when one is set (with `all`: what it provides — rig.env, manifests, kind config, addons, Tiltfile — and where the manifests and kind config resolved to).
Two `!` lines belong to overlays. An older ctrl/.env that still pins `MANIFESTS_DIR=ctrl/k8s/overlays/dev` — rig's examples, before they moved — is reported; load_config ignores it until then. And a kind config without the containerd `config_path` patch is reported whenever a registry mode needs it: registry.sh writes per-host config into certs.d, containerd only reads it if the cluster was created with that patch, and an overlay's own kind file replaces rig's whole template, so dropping it is easy and fails silently.

15
rig/docs/notes/cluster.md Normal file
View File

@@ -0,0 +1,15 @@
# ctrl/cluster.sh
## Why list and free live here
`list` and `free` live in `cluster.sh` rather than in a separate script because a
near-identical second name (cluster / clusters) is a trap — you reach for one and
get the other. One target, one file, unambiguous subcommands.
## Idempotent means convergent
"Idempotent" here means convergent, not "exits early if the cluster exists".
That distinction matters: an interrupted first run can leave a cluster created
but not finished, and returning early on the re-run would strand it there. The
create step is conditional; every step after it always runs, and each one is
individually idempotent.

110
rig/docs/notes/config.md Normal file
View File

@@ -0,0 +1,110 @@
# ctrl/lib/config.sh
## Purpose and precedence
The ecosystem convention is that scripts are standalone with no shared log library, and 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:
```
built-in defaults in load_config; fill only what nothing else set
ctrl/versions.env pinned toolchain + image digests (committed)
ctrl/env.d/<profile> how this machine reaches the world (OPTIONAL, examples ship as *.env.example)
<overlay>/rig.env what runs: addons, namespaces, images (OPTIONAL, lives with the overlay)
ctrl/.env machine-local values and secrets (gitignored)
the caller's env `make cluster up PROFILE=<name>` (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/.
## CONFIG_OVERRIDABLE
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 is deliberately NOT here: it is read back out of the kind config, so the file is the one place that decides it.
REGISTRY_PORT and MANIFESTS_DIR were missing here while ctrl/.env set them, so the caller's env silently LOST to the file for those two, breaking the one precedence rule the header states. Both are now listed. OVERLAY joined with overlays, for the same reason: it is chosen per machine or per call.
## default_cluster_name
The environment's folder name — the overlay's when one is named, else rig's own — reduced to something kind accepts as a cluster name (a DNS label: lowercase alphanumerics and dashes). Run from ctrl/, so rig's folder is the parent.
## _from_ctrl, _abs_from_ctrl
Paths in the config are relative to rig's folder (MANIFESTS_DIR, OVERLAY) or to ctrl/ (KIND_CONFIG), or absolute. Scripts run from ctrl/, so `_from_ctrl` turns a rig-relative path into one usable from there, and `_abs_from_ctrl` into an absolute one for consumers outside bash (ports.sh active, the kind config's hostPath entries).
## derive_port_base
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.
## load_config: RIG_PORTABLE
RIG_PORTABLE skips the machine-local layer. config_snapshot sets it, so a generated standalone kit never carries this machine's .env, which holds local values and, by its own description, secrets.
## load_config: profiles are optional
A profile is an optional overlay, never a prerequisite. rig assumes no configuration: with no profile named, or no env.d/ at all, it runs on the built-in defaults. What IS an error is naming a profile that does not exist, because a typo must not quietly fall back to something else.
## load_config: overlays
An overlay is one folder, outside rig's version control, that holds what runs ([overlay.md](overlay.md)). `OVERLAY` names it; a named overlay that does not exist is an error, like a named profile. With none named, rig's own `examples/starter` is used if it is present — it sets nothing, so a plain rig resolves as it did before overlays — and a rig copied without `examples/` still resolves, with no manifests.
Its `rig.env` is layered after the profile and before ctrl/.env. It may not set PROFILE or OVERLAY, which are chosen before it loads, and the paths it sets are relative to the overlay (load_config rewrites them as it loads the file), so an overlay can be moved without editing it.
## load_config: identity follows the folder
Identity follows the FOLDER — the overlay's when one is named, else rig's — so copying either 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. It is also what lets a project carry rig at `<project>/rig/` without every such project's cluster being called `rig`.
## load_config: host ports
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.md](ports.md) for the reasoning.
## load_config: MANIFESTS_DIR
Where the workload's manifests live, relative to rig's folder or absolute: the overlay's `k8s/overlays/dev` unless something names another. It is the seam that lets the real manifests be versioned away from the installer. `none` means rig applies none (the overlay's Tiltfile does). A named folder that does not exist is an error; the old default `ctrl/k8s/overlays/dev`, pinned by older .env files, is ignored while that folder does not exist and reported by `make check`.
## load_config: NODE_MB
What one node costs, measured rather than guessed. On 2026-09-11 a minimal control-plane node ran at 620 MiB idle and ~728 MiB with a small mock, plus 16 MiB for the local registry: ~745 MiB of working set. 800 rounds that up, and agrees with the 800 MB observed independently on a larger rig. Worker nodes carry no etcd or apiserver and are lighter, so for a multi-node shape this errs high. It is the cluster alone: whatever you deploy comes on top.
It is set here rather than in check.sh because the memory tool and every standalone kit need the same figure.
## render_kind_config
Renders the kind config 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 and OVERLAY_DIR must stay host paths even when this runs inside the installer container. `${OVERLAY_DIR}` renders to the overlay's absolute path, for mounting its folders into the nodes.
## What a standalone kit needs to know
The kit generator (ctrl/standalone.sh) asks these questions so that it never has to know how configuration is stored. Where profiles live, which files are layered and what is derived are config.sh's business and can change freely; the generator only calls these functions.
## config_profiles
Every configuration rig can be run as, one per line: each profile file, or, when there are none, `default`, the built-in configuration load_config uses when no profile is named. Never empty, because rig never needs a profile.
## config_snapshot
The resolved configuration, as `declare -p` lines: exactly what load_config leaves behind, minus the machine-local layer. A kit freezes this in place of load_config, so it carries rig's decisions and not this machine's secrets.
```
config_snapshot <profile> that profile, as any machine would resolve it
config_snapshot --current what THIS machine runs: every overridable key as
resolved here, handed back in as if typed on the
command line, over the same portable resolution.
Values derived from those choices follow them;
anything else the local layer set (credentials)
is not carried. config_left_out names it.
```
Found by difference, not by a list: whatever load_config sets today, it sets. A list here would be one more place to forget a variable.
## config_left_out
What an export of this machine's configuration does NOT carry, by name only: keys the machine-local layer sets that are not choices a caller may override. They are this machine's own (registry and mirror credentials, mostly), so the target has to be told to supply them. Values are never printed.
## config_freeze
A replacement for load_config with a resolution frozen in (a profile, or --current; see config_snapshot), printed as a function definition for a standalone kit to carry. The generator embeds whatever this prints and interprets none of it, so what "frozen" means stays rig's decision.
It keeps load_config's one stated rule: the caller's env wins for anything in CONFIG_OVERRIDABLE. A kit therefore behaves like rig (`OUT_BIN=... rigdeps.sh` still works) rather than like a copy with everything pinned.
What freezing does give up, knowingly: values DERIVED from an overridable one are fixed at generation. Override CLUSTER and the ports stay the ones derived for the original name. Re-deriving would mean carrying the layering itself, which is exactly what a kit exists not to need.

166
rig/docs/notes/deps.md Normal file
View File

@@ -0,0 +1,166 @@
# ctrl/deps.sh
## Purpose and safety
Toolchain installer: detect the host, install a pinned toolchain onto it, then report what it could not do.
It never runs the cluster, never uses sudo or apt, and writes only into `$OUT_BIN` (default `~/.local/bin`). Everything that would touch the host proper — systemd, inotify limits, `.wslconfig`, docker group — is REPORTED for a human to decide on, never performed. That is what makes it safe to run on a machine that already has a working setup.
## Usage
Normally via `make deps`, or directly:
```
deps.sh detect # report host facts only, change nothing
deps.sh list # the pinned versions
deps.sh verify [core|dev] # run what is installed and see if it works
deps.sh fetch [core|dev] [--to DIR] # download + verify into DIR
deps.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.
## Container vs bare host
Runs both inside the installer container and bare on a host. Inside the container, host files are read through `$HOST_ROOT` (mount `/` as `:ro`); bare, it falls back to `/`.
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.
## INVOKED_FROM
Keep the caller's cwd so a relative `--to` resolves where the user expects, not against `ctrl/` once we've moved.
## load_config
Pins arrive through `load_config` like every other setting, not by sourcing `versions.env` here. That is what lets `make standalone` freeze them into a one-file installer: configuration has exactly one way in.
## mb_of
A `/proc/meminfo` field in MB, 0 if the field is absent. `MEMINFO` exists so the tight and does-not-fit branches can be exercised against a real machine's numbers from somewhere else; in normal use it is always `/proc/meminfo`.
## require_amd64
The pins are amd64. Rather than download something that cannot execute and let it fail as "cannot execute binary file: Exec format error", say so here and hand over the commands that produce the right checksums.
## pkg_install_cmd
This never runs a package manager. It names one so the reported action is something you can paste, on the distro you are actually on — an apt line on Amazon Linux 2 is a wrong answer dressed up as help.
## require_linux
Windows outside WSL — Git Bash, MSYS, Cygwin — looks close enough to work and then fails in a pile of confusing ways: no /proc, no docker socket, none of the tooling. Detectable, so name it instead.
## detect: memory
In MB. Whole gigabytes lose nearly half a GB on exactly the machines where it matters: 1874 MB available used to print as "1 GB". Facts only — whether that is enough depends on the profile, which `check.sh` knows and this does not.
## detect: overcommit
How the kernel answers an allocation it cannot really satisfy. With 1 it always says yes and settles up later with the OOM killer, so a cluster that starts cleanly can still lose processes afterwards.
## detect_wsl: systemd
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.
## watch_hostile_fs
Not a path check: `/mnt` is an ordinary mount point and an ext4 disk mounted there is perfectly fine. What matters is the filesystem. The Windows drives arrive as 9p (WSL2) or drvfs (WSL1); network and fuse mounts behave the same way. None of them deliver inotify events, so anything watching files goes quiet without saying why.
## detect_libc
tilt is the one binary here that needs a recent glibc. MEASURED, not guessed: tilt 0.37.6 on Amazon Linux 2 (glibc 2.26) fails with
```
/lib64/libc.so.6: version `GLIBC_2.34' not found (required by .../tilt)
```
which names a symbol rather than the problem. Amazon Linux 2 is a stock WorkSpaces bundle, so this is the likely case, not an exotic one. Report the version now; `verify` catches the actual failure after installing.
## detect_prereqs
What this script needs to do its own job. Reported here so `detect` answers "will install work?" instead of leaving you to find out one download in. Amazon Linux 2 ships without tar, which is exactly the surprise this catches.
## detect_docker
Reachability of the daemon is the real question, and the CLI is only how we ask it. When this runs inside the installer container, Docker necessarily exists on the host — otherwise nothing would be executing — so a missing CLI in there is an installer packaging bug, not a host problem.
The kind-node count check must be an `if`, not `[ ] && echo`: as the last statement in the 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.
## fetch_tgz: --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.
## fix_ownership
The installer 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).
kind writes the kubeconfig as root too; `fetch` hands that back as well when it's a mounted host directory rather than container-local state.
## Tiers (CORE_TOOLS, DEV_TOOLS)
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.
No helm: every addon installs with `kubectl apply -f <url>`, so nothing here has ever invoked it. Add it back the day something actually needs a chart.
ctlptl is `dev` rather than `core` for the same reason kind is: core is "talk to a cluster someone else runs", and ctlptl builds them. It earns its place because it is what wires a cluster to a local registry — without one, an unqualified image name resolves to `docker.io/library/<name>` and there is nothing structural stopping a push there.
docker-compose is `dev` for the same reason, and is here because the distro docker packages ship the daemon and CLI but frequently not the compose plugin — so `docker compose up` fails with "unknown command" on an otherwise working Docker, and nothing about that message names the missing piece.
## What is already on this machine (pin_of)
A tool already on PATH at its pinned version is left where it is. Without this, install downloads a second copy into `OUT_BIN` and then reports the first one as shadowed — noise, and wrong, when both are the same version. That is the normal state of any machine someone set up by hand, whatever directory they happened to choose.
## reported_version
Each tool spells the version question differently, and kubectl has to be told `--client` or it goes looking for a server to ask.
## version_matches
Matched as a whole version token, so 0.37.6 never matches 10.37.60, with the leading v optional either side: kind says v0.32.0, jq says jq-1.8.2, and tilt says v0.37.6 against a pin of 0.37.6.
Bash's own regex rather than grep, deliberately. grep is not the same program on every machine — some builds reject patterns that others accept — and a failed grep inside a count reads exactly like a zero.
## want / DEPS_ONLY
`DEPS_ONLY` narrows a fetch to the tools it names. Unset means the whole tier, which is what an explicit `deps.sh fetch` always gets: "download these into DIR" must not quietly skip something because this machine happens to have it. Only `install()` sets it, to what `detect_toolchain` found missing or mismatched.
## detect_toolchain: compose
compose is the one tool that is normally NOT a binary on PATH. It is a docker CLI plugin, so a machine where `docker compose` works perfectly has no `docker-compose` to find — and probing only PATH would report it missing and re-download a copy that is already there. That is the exact noise the version-aware skip exists to prevent, so ask docker instead.
## verify_tools
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.
Downloading a verified binary proves it is the right file, not that this machine can run it. On an old distro tilt fails here, with a linker error about a missing symbol, and finding that out now beats finding out during a first cluster build.
Output is not piped into `head`. With `pipefail` set, a tool that prints more than one line gets SIGPIPE when head closes the pipe, and the pipeline reports 141 — so a working kubectl was announced as "does not run here", with its own correct version string as the evidence. The first line is taken afterwards, from the string.
## install_compose_plugin
A copy in `OUT_BIN` only gives you `docker-compose`. That hyphenated form is the retired v1 spelling; every compose file written in the last few years assumes `docker compose`, which resolves plugins BY NAME out of a plugin directory. So the binary is fetched like any other and then linked, in your own home — no root, and nothing outside it.
If something else already owns that name — docker-desktop and some distro packages install a real file there — overwriting it would take the plugin away from whatever put it there, so say so and let the user decide.
## install
The plugin is linked only when compose was one of the things fetched: linking a binary that is already satisfied elsewhere on PATH would point the plugin at a copy rig did not install.
The "put OUT_BIN on PATH" advice is only worth giving when something actually landed in `OUT_BIN`. When every tool was satisfied elsewhere, `OUT_BIN` may reasonably be off PATH, and telling the user to add it would be advice to fix nothing.
## main: argument shift
Read the command, THEN shift — and shift only if there is something there. A bare `shift` with no positional parameters returns 1, and under `set -e` that ended the script before a single line was printed: running this with no arguments at all, the documented default, did nothing and said nothing.
## manifest / manifests
The manifests rig's own addons apply are pinned in versions.env like the binaries, and fetched by the same code: `resolve_url` for the source (upstream, artifactory, baked), `verify` for the sum. `manifest <NAME>` makes one present in `vendor/manifests/` (rig's folder, gitignored) and prints only its path, so an addon can apply it; a cached copy whose sum still matches is reused, one that does not is fetched again. `manifests [--to DIR]` fetches all three, which is how the deps-full image bakes them and how an offline machine is given them. Why the addons stopped applying URLs is in versions.md.
## snapshot, and the test hooks
`snapshot [DIR]` writes this machine as a host fixture: the files detect reads, cut down to what it needs (no environment, home or host name), plus the lines detect printed for it. It is in the kit, so the Workspace needs nothing else to take one. Detection reads through `HOST_ROOT`, `MEMINFO`, `OVERCOMMIT_FILE` and `UNAME_S`, so a fixture can stand in for a machine; with `HOST_ROOT` set, the first two default into it. How the fixtures, the clean-container run and the WSL recipe fit together: [installer-testing.md](installer-testing.md).

14
rig/docs/notes/docs.md Normal file
View File

@@ -0,0 +1,14 @@
# ctrl/docs.sh
## Serving without the cluster or python
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.
## Committed SVGs
Rendered SVGs are committed alongside their `.dot` sources for the same reason:
the pages have to read on a machine with no Graphviz installed.

64
rig/docs/notes/env.md Normal file
View File

@@ -0,0 +1,64 @@
# ctrl/.env.example, ctrl/env.d/*.env.example
## ctrl/.env.example: header
Machine-local config. Copy to ctrl/.env (gitignored) and edit. The cluster SHAPE is an optional profile in ctrl/env.d/ — see the *.env.example there. The architecture MODEL lives in arch/<name>.json — not in .env either.
## ctrl/.env.example: CLUSTER
The kubectl context becomes kind-<CLUSTER>. LEAVE THIS UNSET unless you need a name that differs from the directory — it defaults to this folder's name, which is what makes the folder copyable: copy it, rename it, and you get a separate environment with no edits.
## ctrl/.env.example: host ports
LEAVE UNSET — they derive from the directory name so several environments coexist without negotiating (see ctrl/ports.sh). `make check` shows this environment's block; `bash ctrl/ports.sh persist` writes it into ctrl/.env so it stops being derived and becomes fixed. Set a value only to override.
## ctrl/.env.example: OVERLAY
The folder that holds what runs — its settings (`rig.env`), manifests, addons, Tiltfile — kept outside rig's version control: `local/<name>` (gitignored), or a repo of its own anywhere. Relative to rig's folder, or absolute. Unset, rig runs its own `examples/starter`. The cluster, context and port block follow the overlay's folder name. See [overlay.md](overlay.md).
## ctrl/.env.example: MANIFESTS_DIR
Where the manifests live. Leave it unset: the overlay's `k8s/overlays/dev` is the default. Set it only to point somewhere else, relative to rig's folder or absolute:
MANIFESTS_DIR=../platform-manifests/overlays/dev
Older copies of this file set `MANIFESTS_DIR=ctrl/k8s/overlays/dev`, rig's examples before they moved to `examples/`. That value is ignored while the folder does not exist, and `make check` says to delete the line.
## ctrl/.env.example: DEPS_SOURCE
Where the installer 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 installer image; no network at all
## ctrl/.env.example: registry secrets
The registry mode comes from the profile (REGISTRY_MODE). REGISTRY_REMOTE_URL, REGISTRY_USER and REGISTRY_PASSWORD are the secrets it needs, required for mirror/remote.
## ctrl/.env.example: REGISTRY_CA_FILE
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; check.sh reports when it's configured but not trusted. Symptom when missing: `x509: certificate signed by unknown authority`.
## env.d/*.env.example: profiles in general
EXAMPLE PROFILES. rig needs none of these: with no profile it runs on its built-in defaults (lib/config.sh). To use one, copy it to <name>.env in ctrl/env.d/ and name it — PROFILE=<name> in ctrl/.env, or on the command line. It then overlays the defaults; anything it does not set, they still supply. An activated `<name>.env` is gitignored: it is this machine's choice.
A profile says how this machine reaches the world — a registry mirror, an air-gapped install. What runs is an overlay's business ([overlay.md](overlay.md)); its `rig.env` layers above the profile.
## env.d/mirror.env.example
mirror — images through a pull-through cache of an internal registry, with TLS and metrics addons. More nodes or port mappings: edit the kind config (rig's, or the overlay's).
### Real ports (80/443)
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 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 check` checks before you spend the time. Uncommenting also means only one environment can exist at a time.
## env.d/offline.env.example
offline — air-gapped. Everything comes from a local registry that was loaded ahead of time; nothing reaches the internet. Pair with the deps-full image (DEPS_SOURCE=baked) so the toolchain install is offline too, and so the manifests metallb and the other addons apply come from the image, verified, rather than from GitHub (see Dockerfile.deps.md). Their container images still have to be preloaded.
The heavier addons are left out to keep first boot viable.

View File

@@ -0,0 +1,118 @@
# Testing the installer
## Why it needs its own tiers
The installer is `ctrl/deps.sh`. What reaches a machine is its generated one-file kit,
`standalone/default/rigdeps.sh`: the AWS Workspace gets that file and nothing else. The
machines it must work on are exactly the ones you cannot rebuild to test on:
- the Workspace cannot be recreated;
- redoing WSL means rebuilding the machine you work on every day;
- a fresh install happens once per machine, so a bug there is found by the one person least
able to diagnose it.
So each tier is cheaper than the one after it, and each catches what the one before cannot.
| tier | runs | needs | proves | cannot prove |
| --- | --- | --- | --- | --- |
| 1 host fixtures | every `make selftest` | nothing | detection reads each kind of machine right: distro, WSL, memory, overcommit, systemd, `.wslconfig`, the Git Bash refusal | that anything installs |
| 2 clean containers | `make selftest install` | docker, network, ~12 min | a stock Ubuntu 22.04 / Debian refuses cleanly when bare, then installs, verifies and fetches the manifests **as a non-root user**; the offline image works with no network | WSL, the Workspace's own policies, docker itself |
| 3a Workspace snapshot | by hand, when the Workspace changes | the kit, once, on the Workspace | tier 1 replays the real Workspace's shape from then on | its install (that is tier 2's Ubuntu 22.04 plus the real run) |
| 3b WSL throwaway | by hand, before teaching or after a WSL change | a Windows machine | the whole path in a real WSL distro that is not your daily one | Windows-side setup (not built, see below) |
## Tier 1 — host fixtures
`tests/hosts/<name>/` is a stand-in machine:
```
root/ the files detect reads: etc/os-release, proc/version, proc/meminfo,
proc/sys/vm/overcommit_memory, etc/wsl.conf, mnt/c/Users/<u>/.wslconfig
env optional KEY=value lines, e.g. UNAME_S=MINGW64_NT-10.0 (a fact no file carries)
expect.txt "+ text" must appear, "- text" must not, "exit N" (default 0)
```
`bash ctrl/hosttest.sh [DIR...]` runs `deps.sh detect all` with `HOST_ROOT` pointing at the
root (which also sets `MEMINFO` and `OVERCOMMIT_FILE`) and checks the lines. Only host facts are
asserted: docker, PATH and the filesystem belong to the machine running the test.
Shipped: `ubuntu-22.04` (the Workspace's shape: 7.6 GB, overcommit 1, swap in use),
`debian-trixie`, `wsl-bare` (systemd off, no `.wslconfig`), `wsl-ready`, `git-bash`. To add a
case, add a folder. Anything true of one real machine goes with an overlay or in `local/`,
never here.
## Tier 2 — clean containers
`make selftest install` (`ctrl/installtest.sh [IMAGE...]`). It first refuses to run on a stale
kit, then, for `ubuntu:22.04` and `debian:trixie-slim`:
1. **bare:** `install` must refuse and name curl/wget — the bootstrap paradox;
2. **after the one root step** (`apt-get install curl ca-certificates`), as a plain user:
`install dev`, `verify dev`, `manifests --to ~/m`. The dev tier lands in `~/.local/bin`,
the PATH advice is printed, and all three manifests are there, verified.
Then it builds `deps-full` and runs `install` + `manifests` with `--network none`: the
air-gapped path. Measured 2026-09-22: 15 checks, 75 s.
## Tier 3a — the Workspace snapshot
```bash
bash rigdeps.sh snapshot ~/rig-host-snapshot # on the Workspace; the kit is enough
```
It writes the files detect reads, cut down to what detect needs:
- the `os-release` name fields;
- the kernel release;
- four meminfo numbers;
- the overcommit value;
- `wsl.conf`'s section headers and its two keys;
- a `.wslconfig` memory line, under the neutral user `user`;
- `facts.txt` (arch, glibc, date);
- `expect.txt`, the lines detect printed for it.
No environment, no home, no host name: selftest asserts both the file list and the absence
of those.
Carry the folder back, keep it in `local/hosts/workspace/` (gitignored) or with the overlay,
and from then on:
```bash
bash ctrl/hosttest.sh local/hosts/workspace
```
Add your own `+`/`-` lines to its `expect.txt` for what must stay true there. Take a new one
when the Workspace changes (an image update, a memory change).
## Tier 3b — WSL, in a throwaway distro
Never the distro you work in: a second one, imported from a rootfs, removed afterwards. WSL
runs every distro in one VM, so this changes nothing in yours — **except that
`wsl --shutdown` stops both**; don't run it while you work. From PowerShell:
```powershell
# a pristine Ubuntu 22.04 rootfs: from cloud-images.ubuntu.com/wsl/, or
# `wsl --export Ubuntu-22.04 ubuntu-22.04.tar` of an untouched install kept for this
wsl --import rig-test C:\wsl\rig-test .\ubuntu-22.04.tar
wsl -d rig-test -u root -- bash -c "apt-get update && apt-get install -y curl ca-certificates && useradd -m t"
copy .\rigdeps.sh \\wsl$\rig-test\home\t\
wsl -d rig-test -u t -- bash -lc "cd ~ && bash rigdeps.sh detect all && bash rigdeps.sh install dev && bash rigdeps.sh verify dev && bash rigdeps.sh snapshot ~/snap"
# carry \\wsl$\rig-test\home\t\snap back as a fixture, then:
wsl --unregister rig-test
```
What to look for: detect says `WSL`, and names the systemd and `.wslconfig` steps; install and
verify pass. This is the tier to run before teaching Windows users, or after a WSL update.
Nothing in rig automates it: a throwaway-distro harness is the multi-distro machinery that
was removed on purpose.
## Not built: a Windows-host installer
Everything above starts inside Linux. The Windows side — enabling WSL, getting a distro, and
the tools a Windows user needs before rig (python and the like) — is still manual, in the
README's "Starting from plain Windows". A Windows-host installer is recorded for the future,
for teaching; it would need tier 3b's approach, a machine that is not your daily one, to test.
## The hooks the tiers rely on
`deps.sh` reads host files through `HOST_ROOT` (`host_file()`), memory through `MEMINFO`, the
overcommit mode through `OVERCOMMIT_FILE`, the kernel name through `UNAME_S`, and WSL through
`host_file /proc/version`. With `HOST_ROOT` set, `MEMINFO` and `OVERCOMMIT_FILE` default into
it. The deps image relies on `HOST_ROOT=/host` for the same reason: a container asking about
its host.

View File

@@ -0,0 +1,29 @@
# ctrl/k8s/kind-config.yaml.tpl
## Why a template
The cluster: one node by default — add nodes or port mappings by editing the file, then `make cluster reset`.
It is a TEMPLATE rather than a plain kind-config.yaml because a rig (or an overlay) is copied and renamed to make a second environment, and both the cluster name and the host port follow the folder. A checked-in literal would make every copy collide on both — which is exactly why every other project here, with its literal kind-config.yaml, has only one of itself. 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.
A kind config is fixed at creation: to change the cluster, edit the file, then `make cluster reset`. lib/config.sh reads the node count back out of it, so nothing restates it.
## An overlay's own kind config
An overlay may carry its own `kind-config.yaml.tpl` (see [overlay.md](overlay.md)); it replaces this whole file, rendered the same way, so start from a copy of this one. Keep the containerd `config_path` patch: `make check` reports its absence whenever a registry mode needs it. A project that builds its own cluster through rig can also pass any file as `KIND_CONFIG=<path>`.
## Substituted variables
Substituted by ctrl/cluster.sh: CLUSTER, NODE_IMAGE, HTTP_PORT, HOST_WORKDIR (rig's folder), OVERLAY_DIR (the overlay's folder, for mounts). The header comment names them without the `${...}` braces so that line survives the substitution.
## Node count
The node count is READ BACK from this file by lib/config.sh, so this YAML is the source of truth for it — there is no second place to update.
## containerdConfigPatches
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.
## extraPortMappings
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.

96
rig/docs/notes/mem.md Normal file
View File

@@ -0,0 +1,96 @@
# ctrl/mem.sh
## Purpose
How much memory this machine will actually give you before something dies. This is rig's memory tool, and the standalone rigmini.sh is generated from this file.
There are two numbers and they are rarely the same. `status` reports what the machine ADVERTISES and what is quietly capping it. `push` finds what it will SURVIVE, by allocating until it stops. `all` does both and weighs the result against what this profile's cluster needs.
The gap between them is the whole reason this exists. Under WSL the cap lives in .wslconfig; in a container or a managed workspace it is a cgroup limit, and there /proc/meminfo reports the HOST's memory while the kernel kills you at a fraction of it. A script that only read MemTotal would confidently report 32 GB on a box that OOMs at 2.
Runs on native Linux and under WSL. On WSL the memory you see is a VM allocation that can be raised, and the commonest failure is raising it without restarting, so status compares what .wslconfig says with what actually booted.
It reports and instructs. It never raises a limit, frees anything or installs a package. The one write it can make is `backup`, which copies .wslconfig beside itself, so that `restore` has something to put back after a hand edit.
Usage:
```
mem.sh status what it has, what caps it
mem.sh push [--to GB] [--to-oom] climb until it stops
mem.sh all [--budget GB] both, then the verdict
mem.sh backup | restore .wslconfig, WSL only
```
## require_linux
Windows outside WSL (Git Bash, MSYS, Cygwin) looks close enough to work and then fails in a pile of confusing ways: no /proc, no docker socket, none of the tooling. It is detectable, so name it instead.
## CG_MAX_FILE / CG_CUR_FILE
Where a cgroup records this cgroup's own limit and usage. Set once by find_cgroup, because every later reading needs both, and hunting for the files on each call would be the slow part of the poll loop.
## find_cgroup
Inside a container the cgroup namespace makes the top of the tree BE the container's own cgroup, so the unqualified path is already the right one. On a host it is the root cgroup, which is never limited; hence the second attempt via /proc/self/cgroup, which names the slice this shell is in.
## cgroup_cap_mb
Returns the cap in MB, or "" when there is none worth reporting. cgroup v2 spells unlimited "max"; v1 spells it as a number near 2^63, which is why this compares against MemTotal rather than testing for a magic value. A "limit" above the machine's own memory is not a limit, however it is written.
## headroom_mb
How much room is left RIGHT NOW, from whichever accounting actually governs. In a capped container /proc/meminfo describes the host and is worse than useless for this: it would report tens of gigabytes free on a box that is one allocation from being killed.
## wslconfig_path
/mnt/c/Users can hold several real accounts (a renamed login leaves the old directory behind), so picking the first alphabetically is a coin toss. Ask Windows, then fall back to whichever profile actually owns a config.
## status: overcommit
overcommit_memory=0 is the default heuristic: a large allocation is granted on a guess, and the reckoning arrives later as an OOM kill rather than as a failed malloc. It is why `push` touches every page it asks for.
## status: WSL
WSL keeps its cap on the Windows side, in a file this shell can read but not usefully apply: the change costs a full VM restart. Report it, and report the commonest mistake, which is editing it and not restarting.
## backup
Backups are timestamped and never overwritten: a backup that can destroy itself on a second run is not a backup.
## restore
Newest is the right default (undo the last edit), but if you backed up *after* editing, the state you want is older. The rest are shown so a no-op restore is obviously a no-op rather than a mystery.
## allocator
The child allocates and stops itself; the parent only watches. That split is the point: under --to-oom the allocating process is expected to be killed, and something has to survive to say how far it got.
### OOM score
The child raises its own OOM score to the maximum so the kernel picks THIS process first. Raising needs no privilege (only lowering does). Without it, the kernel is free to choose your shell, your ssh session or dockerd; on a box you are still using, that is not an acceptable coin toss.
### Writing straight into the array element
Each chunk is written STRAIGHT INTO the array element (`printf -v "arr[$i]"`). The obvious spelling, building one chunk and `arr+=("$chunk")`, costs three copies per step, not one: the template stays resident, expanding "$chunk" makes a temporary word, and the append makes the element. A 128 MB step then needs 384 MB transiently, and on a small box it is killed on the first append while reporting a third of the true ceiling.
printf -v into a subscript also means every page is written, so it is resident rather than merely promised: the only kind of allocation that measures anything under heuristic overcommit.
### First swap
Worth calling out separately from the ceiling: this is where the box stops being fast and starts being unusable, which for a scheduler is a different and earlier problem than being killed.
## push: step size
A step is worth about a sixty-fourth of the ceiling: enough resolution to find the edge, few enough lines to read, and small enough that the transient cost of one allocation never dominates a small box. A fixed size cannot do all three: 128 MB is fine on 16 GB and absurd on 512 MB.
## push: floor
Stop with a cushion rather than riding it to the kill. How big a cushion depends on what it is protecting. Under a cgroup cap, running out kills only this container's own processes, so it need cover no more than the shell that prints the result, and a 512 MB cushion on a 1 GB box would halve the answer. On a host there is everything else to protect, and the OOM killer does not promise to pick the process that caused the problem.
## push: Ctrl-C
INT kills the child and lets the summary print anyway, so an impatient Ctrl-C still tells you how far it got and, more importantly, still gives the memory back.
## push: claimed vs. measured
The gap between the claim and the measurement is the finding, but only when the BOX chose where to stop. An empty $stop means the child was ended rather than deciding to end; anything else (--to, the floor) is a stop we asked for, and flagging those as short of the ceiling would put a warning on every deliberately small run.

171
rig/docs/notes/overlay.md Normal file
View File

@@ -0,0 +1,171 @@
# Overlays: what runs lives outside rig
## Why
rig is the machine: the toolchain, the cluster, the registry, the port block, the
dev loop's plumbing. What runs on it — the services, their manifests, their
images, their settings — belongs to whoever owns that work, changes at a
different rate, and often cannot be shared at all. Keeping both in one tree meant
editing rig's own files to use it, and then carrying those edits into every copy.
So the use case is one folder, the **overlay**, kept outside rig's version
control. rig reads it; rig never writes into it and never knows what is in it.
The dependency points one way: an overlay knows about rig, rig knows about
overlays in general and about none in particular.
## Where an overlay lives
```
rig/local/<name>/ gitignored by rig: an overlay with no version control of its own,
or a clone of its own repo
anywhere/<name>/ a repo of its own, named by path
<project>/ a project folder that carries rig at <project>/rig/ (the vendored
layout, below)
```
Name it with `OVERLAY` — in `ctrl/.env` for this machine, or per call:
```bash
OVERLAY=local/myenv make cluster up
```
Relative paths are relative to rig's folder. With none named, rig uses its own
`examples/starter`, which sets nothing, so a plain rig behaves as it always did.
An overlay that is named and missing is an error; it never falls back.
## What rig reads from it
Every piece is optional.
| in the overlay | what rig does with it |
| --- | --- |
| `rig.env` | a config layer (below). Any key a profile could set. |
| `k8s/overlays/dev/` | the default `MANIFESTS_DIR`: rig's Tiltfile applies it with kustomize |
| `kind-config.yaml.tpl` | the default `KIND_CONFIG`: the cluster's shape, rendered like rig's own |
| `addons/<name>.sh` | an addon, found before rig's `ctrl/addons/<name>.sh` of the same name |
| `Tiltfile` | the workload's half of the dev loop, included by rig's `ctrl/Tiltfile` |
Anything else in the folder is the overlay's own business: Dockerfiles, DAGs,
folders of repos or data it mounts, its `.gitignore`, the secrets its kustomize
generators read. rig does not look.
## Layers
```
built-in defaults < ctrl/versions.env < ctrl/env.d/<profile>.env < <overlay>/rig.env < ctrl/.env < the caller
```
A profile says how this machine reaches the world (a registry mirror, an
air-gapped install); an overlay says what runs. `ctrl/.env` is still this
machine's, and the caller still wins over everything.
`rig.env` may not set `PROFILE` or `OVERLAY`: both are chosen before it loads.
The paths it sets (`MANIFESTS_DIR`, `KIND_CONFIG`) are relative to the overlay.
`MANIFESTS_DIR=none` means rig applies no manifests and the overlay's Tiltfile
does, e.g. when kustomize needs flags.
## Identity
With an overlay named, the cluster, the kubectl context and the port block
follow the overlay folder's name, sanitised the same way a rig folder's name is.
One rig can therefore serve several overlays, each in its own cluster, and a
project that carries rig at `./rig` does not name every cluster `rig`.
`ports.sh persist` refuses while an overlay is set: it writes to rig's
`ctrl/.env`, and a pin there would follow every overlay.
## The Tiltfile handoff
rig's `ctrl/Tiltfile` does rig's part — the context guard, `default_registry`,
the manifests, the namespaces they use — then publishes the facts and includes
the overlay's `Tiltfile`:
```
RIG_CLUSTER RIG_CONTEXT RIG_HTTP_PORT RIG_HTTPS_PORT RIG_TILT_PORT RIG_REGISTRY RIG_OVERLAY_DIR
```
Read them with `os.getenv`. An included Tiltfile runs from its own folder, so
every relative path in it (`docker_build` contexts, `sync`, `deps`, `local`) is
relative to the overlay — it never needs a path back into rig.
## Addons
An addon is a bash script run by `ctrl/addons.sh` from rig's `ctrl/`, with
`RIG_CTRL` exported. It starts like this, sources the config and does its work:
```bash
cd "${RIG_CTRL:?run it through rig: bash ctrl/addons.sh install}"
source ./lib/config.sh
load_config
```
`OVERLAY_DIR` is set, so an addon can find files beside it
(`$(_from_ctrl "$OVERLAY_DIR")/...`). rig's own `ctrl/addons/` holds only what
makes a cluster work (metallb, cert-manager, metrics-server);
`examples/data/addons/` shows workload ones.
## The kind config
An overlay's `kind-config.yaml.tpl` replaces rig's whole file, so start from a
copy of `ctrl/k8s/kind-config.yaml.tpl` and keep its containerd `config_path`
patch: `registry.sh` needs it, and `make check` says so when it is missing.
`${OVERLAY_DIR}` renders to the overlay's absolute path, for mounts:
```yaml
extraMounts:
- hostPath: ${OVERLAY_DIR}/datadir
containerPath: /rig/datadir
```
A kind config is fixed when the cluster is created: after changing it,
`make cluster reset`.
## The vendored layout
A project folder can carry rig inside it and be the overlay itself:
```
<project>/
Makefile the forwarder below
rig.env k8s/ Tiltfile kind-config.yaml.tpl addons/ ...
rig/ rig, placed as it is; tracked or ignored by the project, its call
```
The forwarder runs rig with `OVERLAY` set to this folder. It passes `OVERLAY` in
the environment, not as a make argument, so rig's own `$(shell ...)` sees it
under make 4.3 as well:
```make
HERE := $(patsubst %/,%,$(dir $(abspath $(lastword $(MAKEFILE_LIST)))))
ifeq ($(wildcard $(HERE)/rig/Makefile),)
$(error rig/ is missingthis folder is an overlay; put rig in ./rig)
endif
GOALS := $(or $(MAKECMDGOALS),help)
.PHONY: $(GOALS)
$(firstword $(GOALS)):
@OVERLAY='$(HERE)' $(MAKE) --no-print-directory -C '$(HERE)/rig' $(GOALS)
$(wordlist 2,$(words $(GOALS)),$(GOALS)):
@:
```
The cluster is then named after `<project>`, exactly as a copied rig named
`<project>` was, so moving a copied rig to this layout keeps its cluster and ports.
`rig.env` holds no secrets, by this contract. A repository whose `.gitignore` has a
broad `*.env` (a common secrets rule) would still hide it, so an overlay living in
such a repo re-includes it in its own `.gitignore`: `!rig.env`.
## Moving a copied rig to an overlay
A rig copied into a project and edited there splits cleanly:
| was, in the copy | goes to |
| --- | --- |
| `ctrl/k8s/base`, `ctrl/k8s/overlays` | `k8s/` |
| the workload parts of `ctrl/Tiltfile` | `Tiltfile` (paths now relative to the overlay) |
| `ctrl/env.d/<name>.env` | `rig.env` |
| edits to `ctrl/k8s/kind-config.yaml.tpl` | `kind-config.yaml.tpl` (`${HOST_WORKDIR}``${OVERLAY_DIR}`) |
| workload addons | `addons/` |
| Dockerfiles for the workload | beside the Tiltfile |
| `ctrl/.env` | `rig/ctrl/.env` (this machine's; drop a `MANIFESTS_DIR=ctrl/k8s/overlays/dev` line) |
| everything else of rig's | replaced by rig as it is |

38
rig/docs/notes/ports.md Normal file
View File

@@ -0,0 +1,38 @@
# ctrl/ports.sh
## Why each environment gets a port block
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.
## active
The resolved facts a consumer outside bash needs, machine-readable:
```
CLUSTER KUBECONTEXT HTTP HTTPS TILT REGISTRY MANIFESTS_DIR OVERLAY_DIR
```
Identity and ports together, because they are one fact set: both derive from a folder name (the overlay's when one is named) so that copies never collide. A consumer needs all of them or none, and fetching them separately is how two end up disagreeing. MANIFESTS_DIR and OVERLAY_DIR ride along because the one consumer that needs the addressing is the one that needs to know what to deploy and whose Tiltfile to include.
The two paths are absolute, or `-` when there is none: an empty field would shift every later one. OVERLAY_DIR was appended rather than inserted, so readers that take fields by position kept their indexes.
Space-separated, so the paths must not contain whitespace; `active` refuses rather than print a line that splits wrong. Everything else in rig already assumes that of paths; kind, docker and kubectl all do.
## persist, with an overlay
`persist` writes into ctrl/.env, which belongs to this rig, not to an overlay. With OVERLAY set, a block pinned there would follow every overlay this rig later runs, and two of them would then share ports — the collision the derivation exists to prevent. So it refuses and says so; an overlay's ports stay derived from its folder name.
`derive` answers a DIFFERENT question (what the directory name alone implies) and deliberately ignores ctrl/.env. Configuring anything from it would silently contradict the rule that "anything already in ctrl/.env wins". `active` is what anything downstream should read.
Why this exists at all: the cluster name is not the bare directory name. default_cluster_name() lowercases it and replaces every character outside [a-z0-9-], because it has to be a DNS label. Re-deriving that in another language is how a copy in `My_Project/` ends up guarding the wrong context.

View File

@@ -0,0 +1,41 @@
# ctrl/registry.sh
## Registry modes
Registry plumbing. This is the seam — not a tool. Four modes, selected by
`REGISTRY_MODE` in the active profile:
- **none** — Tilt builds straight into the node. No registry at all, and so no
guard against an outward push: an unqualified image name means
`docker.io/library/<name>`, and only Tilt's kind detection stands between that
and a real push. Throwaway use only; every profile here now defaults to `local`
instead.
- **local** — a `registry:2` container wired into the cluster.
- **mirror** — the same container, but configured as a pull-through cache of the
corporate registry. This is 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.
## Why a script rather than ctlptl
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.
## CA trust (install_ca_into_nodes)
A corporate registry is almost always fronted by an internal CA, and trust has to
reach three separate places. Nothing does this for you, and the symptom when it's
missing is an opaque:
x509: certificate signed by unknown authority
1. the host docker daemon — `/etc/docker/certs.d/<host>/ca.crt` (needs root)
2. every kind node's containerd — nodes do NOT inherit host trust
3. anything doing HTTPS from inside the cluster, in its own trust store
`registry.sh` handles (2) because it's ours to handle. (1) is reported by
`check.sh` since it needs root. (3) belongs to the workload.

View File

@@ -0,0 +1,99 @@
# ctrl/selftest.sh
## Purpose
What rig has settled, written down as assertions.
These are documentation that runs. Each check is ONE decision that has already been made, with the reason above it: not coverage, and deliberately not an exhaustive sweep of use cases. rig's own index says a rule without its reason gets overridden the first time it is inconvenient; a rule nobody can restate is worse. So the test says what was decided, and failing it should read as "you are about to undo this" rather than "something broke".
Scope, on purpose:
- No cluster, no docker, no network. It must be cheap enough to actually run.
- It asserts about RIG. `make check` asserts about the MACHINE and never fails; this exits 1, the way `make standalone check` does.
- What actually deploys is not testable here. `tilt ci` stays a manual step.
## rig needs no profile
rig assumes no configuration. A profile is an overlay on built-in defaults, so a rig with no `env.d/` at all must resolve, report, and still generate a kit. Naming a profile that does not exist must still be an error, because a typo that silently fell back to the defaults would be worse than a failure.
## the ports.sh active contract
`ports.sh active` is read POSITIONALLY by two other files: the Makefile takes `$(word 2)` and `$(word 5)`, the Tiltfile takes `_facts[0]..[7]`. Insert a field in the middle and nothing errors: Tilt simply guards on the wrong context or binds the wrong port. The field count and order are the contract, so they are pinned here rather than left to whoever edits `ports.sh` next. The two paths are absolute or `-`, never empty: an empty field would shift the ones after it just the same.
## the caller's env beats the files
`lib/config.sh` states one precedence rule: `versions.env` < `env.d/<profile>` < `ctrl/.env` < the caller's env. It is enforced by `CONFIG_OVERRIDABLE`, a hand-maintained list, and a key missing from it loses to the file SILENTLY. `REGISTRY_PORT` and `MANIFESTS_DIR` were both missing on 2026-09-13 and were found by accident.
So the loop is generated FROM the list: add a key to `CONFIG_OVERRIDABLE` and the test starts asking about it without anyone remembering to come here. Three keys name something that must exist and are validated at load, so they get a real alternative rather than a sentinel.
## one derivation, not three
The Makefile used to compute the cluster name itself and sed `TILT_PORT` out of `ctrl/.env`: a second derivation of values `lib/config.sh` already owns, which could disagree with it after `ports.sh persist`. It now reads `ports.sh active`. Nothing structurally prevents the sed coming back, so the agreement is asserted against the real `make -n` output rather than against the source.
`--no-print-directory` and a grep, not `tail -1`: run from `make selftest` this is a RECURSIVE make, and the "Entering/Leaving directory" lines go to STDOUT. `tail -1` then reads "make[1]: Leaving directory ..." and both checks fail, but only when invoked through make, never when the script is run directly. A test that passes one way and fails the other is worse than no test.
## identity follows the folder, safely
The cluster name is NOT the bare directory name: kind needs a DNS label, so `default_cluster_name` lowercases it and replaces everything outside `[a-z0-9-]`. Re-deriving that anywhere else is how a copy ends up guarding the wrong context, which is exactly why the Tiltfile asks instead of computing.
## ports are stable across versions
Not a change-detector. The block is derived, never stored, so if the derivation shifts then every EXISTING environment's ports move underneath it: a running cluster keeps its old ports while rig starts reporting new ones, and `ports.sh show` stops describing reality. Anchored to three known names.
## rig stays standalone
rig sits inside a host project's tree but must be copyable straight out of it: no imports, no paths, no assumption the host is there. This grep is the whole test of that claim, and until it was added it lived only in prose and in whoever remembered to run it.
The pattern is assembled from fragments so the file does not match ITSELF. Writing it literally would fail forever; excluding the file instead would put a blind spot in the one check that guards the boundary. It includes the host project's word for a backing service, which rig's workload addons carried until they left, and skips `local/`, where overlays live and may say anything.
## scratch copies
Every check that changes something does it in a copy made by `copy_rig`: without `local/` (overlays, possibly someone else's, possibly large) and `def/`, and without this machine's `PROFILE`, `OVERLAY`, `CLUSTER` and `MANIFESTS_DIR` choices, so a check sets exactly what it tests.
## what runs is an overlay; rig only reads it
The overlay decisions (docs/notes/overlay.md), each against a throwaway overlay in a scratch copy: with nothing named, the same cluster, ports, addons, node count and kind config as before overlays existed; `rig.env` between the profile and `ctrl/.env`, the caller above all; identity from the overlay's folder; its paths relative to itself; a named overlay that does not exist, or a `rig.env` that tries to choose the profile or the overlay, is an error; an overlay's addon found before rig's own and run from rig's `ctrl/`; `persist` refusing; `make -n tilt OVERLAY=...` asking for the overlay's context (make before 4.4 would not pass it to `$(shell)`).
And the two that make an overlay safe to hold someone else's work: rig writes nothing into it (a checksum of the folder before and after `active`, `addons list`, a kind render, `standalone write` and `export`), and nothing from it — neither a value nor its path — reaches a committed kit.
## withdrawn stays withdrawn
One check per entry in STALE.md, each asserting that the withdrawn thing has not come back. The reasoning stays in STALE.md; the check is what makes it more than prose.
## the Tiltfile hardcodes nothing
Every other Tiltfile on this machine writes its slug in five or six times by hand, so a copied project deploys into the original's cluster until someone edits all of them. rig's asks `ports.sh`. A literal `kind-<name>` in it would mean that has been undone.
## standalone kits are generated and current
The kits under `standalone/<profile>/` are rig flattened into single files, one per profile. A kit left behind by a change to rig is exactly the drift they replaced (`rigmini.sh` once said 2 GB per node long after rig measured 800 MB), so a stale kit fails here rather than waiting to be noticed on another machine.
## kit Makefiles call only real verbs
Each kit's Makefile exists so nothing wrapping these scripts has to GUESS how to call them. A generated Makefile once did guess: `rigmini.sh on`, not a verb, and a bare `rigdeps.sh` for "check and report", which installs. So every target's default verb must be one its script's own dispatch accepts, read from that dispatch, not from a list that could drift from it.
## export carries choices, not credentials
An export is "take the setup I have here somewhere else", so it carries this machine's CHOICES (profile, ports, manifest dir) and never its credentials: `ctrl/.env` can hold registry and mirror logins next to those choices. The committed per-profile kits carry neither, since they must be the same on any machine. Proven with sentinel values in a scratch copy, because the real `ctrl/.env` may have those keys empty, and an empty value proves nothing.
## the dev loop parses — tilt and kubectl, no cluster
Parsing the Tiltfile for real is the only way to know it still evaluates. Tilt snapshots a kubectl context first, but it never contacts the cluster while evaluating: given a throwaway kubeconfig whose entries are kind-named (Tilt only runs `local()` freely for contexts it recognises as local) and a `kubectl` that swallows `apply`, `tilt alpha tiltfile-result` evaluates rig's Tiltfile with the starter overlay included, and reports the resources it would deploy.
It runs as `rig`, as a copy under another name — the case that used to stop at load (✖ S4) — and with the data overlay, whose namespace is used but not declared. Skipped, not failed, without tilt or kubectl.
## rig's addons apply verified files, never URLs
The offline example profile must install rig's addons with no network. Each addon therefore asks `deps.sh manifest <NAME>` for a pinned manifest, verified on disk, instead of applying a URL; the check fails if a URL comes back or a manifest is asked for without a pinned sum (versions.md). Their container images still need preloading, and nothing here pretends otherwise.
## the examples are overlays that work as shipped
`examples/` is what real overlays are copied from, so every addon there must parse, and every DAG must be valid Python. What they deploy is exercised by the parse checks above, not here.
## the installer detects what each kind of machine needs
The machines the installer must work on — the Workspace, a WSL install — are the ones you cannot rebuild to test on, so their shapes are replayed from fixtures (`tests/hosts/`, run by `hosttest.sh`). And a snapshot is carried off a machine that may be someone else's, so it must hold only what detect reads and nothing that names the machine or the person; that, and that it replays as the machine it was taken on, is asserted on a snapshot of the machine running the test.
## make selftest install
The installer on clean machines: stock Ubuntu 22.04 and Debian containers, the generated kit, a plain user; then the offline image with no network. Docker, network and a minute or two, so it is its own verb rather than part of the cheap run. See [installer-testing.md](installer-testing.md).

View File

@@ -0,0 +1,31 @@
# ctrl/standalone.sh
## Purpose
Generates the standalone kits: single-file versions of rig's own tools, one folder per profile, for machines the full rig is not going to.
A kit is a pure function of rig as it is right now. It gains nothing rig lacks and loses nothing rig has: improve rig, regenerate, and every kit follows. Nothing in `standalone/<profile>/` is ever edited by hand.
## The contract
What this file does NOT know, on purpose: which tools rig has, what they are called, how its libraries are split, where configuration lives or what it contains. Rig will change shape (scripts get split, renamed and grow new libraries), and a generator that encoded today's layout would quietly produce a wrong kit the first time it did. So it works from a contract a script opts into, and from nothing else:
1. A marker comment, alone on a line near the top, declares an entry point: `(hash) rig:standalone <kit-name> <default-verb>`. The default verb must only REPORT: it is run as a smoke test.
2. Every `source` an entry point makes names a `.sh` file by a path that resolves relative to the entry point. Libraries may source further libraries however they like; bash follows those itself.
3. Configuration enters through `load_config`, and the libraries provide `config_profiles`, `config_freeze <profile|--current>` (which prints a replacement `load_config` with that resolution frozen in) and, for an export, `config_current_profile` and `config_left_out`. How config is layered, stored, derived or frozen is rig's business; the generator only asks, and embeds the answer without interpreting it.
## Bash does the resolving
Bash does the resolving, not a parser in the generator. Libraries are sourced in a clean shell and read back with `declare -f` and `declare -p`, so any structure bash can load, this can flatten.
## Every kit is proven before it is written
Every kit is PROVEN to stand alone before it is written: no `source` left, no path into rig's tree in its code, `bash -n` clean, and its default verb run in an empty directory with nothing from rig present. A shape the generator has never seen either passes that, or generation stops and names the kit, the file, the line and what is wrong. It never writes a kit that only looks finished.
## Usage: write, check, export
- `standalone.sh write`: generate every kit into `standalone/<profile>/`.
- `standalone.sh check`: generate into a scratch dir and fail if any kit differs.
- `standalone.sh export DIR`: ONE kit for the configuration this machine runs (its profile plus the choices in its local config, WITHOUT its credentials), written outside the repo.
`write` and `check` are what gets committed: one kit per profile, identical on any machine. `export` answers the other question, "take the setup I have here somewhere else", so it reflects this machine, and for exactly that reason it never lands in the repository.

Some files were not shown because too many files have changed in this diff Show More