Compare commits
77 Commits
rig-fold
...
ec31abecea
| Author | SHA1 | Date | |
|---|---|---|---|
| ec31abecea | |||
| c669662088 | |||
| 2b36799acc | |||
| 85276d34c3 | |||
| 8185d16c07 | |||
| f00055033e | |||
| d8e7de1c59 | |||
| 100f389719 | |||
| 9021cbe3c1 | |||
| 73998020b3 | |||
| b693e324ef | |||
| 12ddd01940 | |||
| 5ff513fcc5 | |||
| 4fe3718795 | |||
| 514efc5e52 | |||
| 8fad2223f3 | |||
| 45018e4743 | |||
| 907b616d1b | |||
| b3cf24e750 | |||
| 88adcd68bc | |||
| 54c577a700 | |||
| 0ce36b98e4 | |||
| 3bbfa413a3 | |||
| 2ef6139957 | |||
| 55c0d73ffe | |||
| 0b227cbc0e | |||
| 60527645f2 | |||
| dee899d86f | |||
| 8f7e409ec4 | |||
| f4b2c15a9f | |||
| 2a0a793f19 | |||
|
|
aeeb26f4e9 | ||
| 9c963514f1 | |||
| 565cecfb50 | |||
| 1752a95408 | |||
| 1dc9d38c80 | |||
| 6a005dae3b | |||
| de5b1b7ea8 | |||
| 6dbc83a449 | |||
| 730ebaff2f | |||
| dd17021402 | |||
| 19feac6d57 | |||
| 809a13eebe | |||
| 004b397b94 | |||
| 29e30693ea | |||
| 3f7f6c988d | |||
| 3e864aa919 | |||
| c3fe4422f2 | |||
| 99b1988504 | |||
| 5219bd5edb | |||
| f7910bf42b | |||
| 24aeadde83 | |||
| 5391f50755 | |||
| fed5d92034 | |||
| 7b70b7edc6 | |||
| 74e246e67a | |||
| e05f8f1fca | |||
| b102ab8de7 | |||
| 2f3e9c2634 | |||
| 86d051da48 | |||
| 6ce24586bd | |||
| 7242b09e3a | |||
| fbf47980d9 | |||
| b9238040a6 | |||
| aba696df79 | |||
| 974679a432 | |||
| 26f99265ca | |||
| a29e0708e8 | |||
| a9df70cde0 | |||
| e0426ecb01 | |||
| 37c4d588ea | |||
| 160ee31b8c | |||
| 358b98f826 | |||
| 7cb892ccfe | |||
| 542d704da4 | |||
| 49a9f8ee57 | |||
| 966f8fc821 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -40,7 +40,5 @@ cfg/dlt/
|
||||
# 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. The negation must name the full path for the same
|
||||
# reason — `*-rig/` is unanchored and matches at any depth, including rig/sample-rig.
|
||||
# rig/.gitignore cannot see it.
|
||||
*-rig/
|
||||
!rig/sample-rig/
|
||||
|
||||
@@ -129,7 +129,7 @@ Every script stays runnable on its own — the standalone rule holds:
|
||||
```bash
|
||||
python build.py --cfg amar # -> gen/amar/
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
18
Makefile
18
Makefile
@@ -13,7 +13,7 @@
|
||||
# make component ARGS="publish soleprint-ui /tmp/out --dist"
|
||||
# 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
|
||||
# this only saves typing.
|
||||
#
|
||||
@@ -42,7 +42,7 @@ $(eval $(ARGS):;@:)
|
||||
endif
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
.PHONY: help build start stop dist docs cluster deploy component
|
||||
.PHONY: help build start stop dist theme ui docs cluster deploy component
|
||||
|
||||
help: ## list targets
|
||||
@grep -hE '^[a-z]+:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16
|
||||
@@ -58,8 +58,18 @@ start: ## run a built room [<room>] [-d] [--build]
|
||||
stop: ## stop a running room [<room>]
|
||||
bash ctrl/stop.sh $(or $(ARGS),$(ROOM))
|
||||
|
||||
dist: ## compile the plexus UIs to single files [<room>]
|
||||
bash ctrl/dist.sh $(or $(ARGS),$(ROOM))
|
||||
dist: ## compile the plexus UIs to single files [NAME|FILE]
|
||||
bash ctrl/dist.sh $(ARGS)
|
||||
|
||||
# ── theme ──────────────────────────────────────────────────────────────────
|
||||
|
||||
theme: ## ad-hoc pages [new|parts|bake|check|export|run FILE]
|
||||
bash ctrl/theme.sh $(or $(ARGS),bake)
|
||||
|
||||
# ── ui ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
ui: ## slices of the UI [list|scan DIR|slice FILE|check FILE|reference|selftest]
|
||||
bash ctrl/ui.sh $(or $(ARGS),list)
|
||||
|
||||
# ── docs ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
26
berth/.gitignore
vendored
Normal file
26
berth/.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# 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/
|
||||
|
||||
# Snapshots of a remote box's state (`host.sh snapshot --run`). Not secrets —
|
||||
# the probe refuses to read environments, compose files or logs — but machine
|
||||
# state from production, and never source. Anchored to ctrl/ for the reason above.
|
||||
ctrl/.snapshots/
|
||||
78
berth/Makefile
Normal file
78
berth/Makefile
Normal 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|snapshot|check] (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
250
berth/README.md
Normal 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
104
berth/STALE.md
Normal 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
18
berth/ctrl/.env.example
Normal 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
58
berth/ctrl/certs.sh
Normal 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
153
berth/ctrl/check.sh
Normal 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
81
berth/ctrl/dns.sh
Normal 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
27
berth/ctrl/docs.sh
Normal 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
14
berth/ctrl/env.d/aws.env
Normal 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
17
berth/ctrl/env.d/gcp.env
Normal 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
101
berth/ctrl/estate.sh
Normal 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
|
||||
347
berth/ctrl/host.sh
Normal file
347
berth/ctrl/host.sh
Normal file
@@ -0,0 +1,347 @@
|
||||
#!/usr/bin/env bash
|
||||
# The remote box — the other half of the inventory.
|
||||
#
|
||||
# Usage:
|
||||
# ./host.sh status|ports|services announce a batch; run nothing
|
||||
# ./host.sh snapshot [--admin] print the probe that would run
|
||||
# ./host.sh snapshot --run [--admin] run it, save it, then check it
|
||||
# ./host.sh check [<snapshot-dir>] is the box healthy? offline, never ssh
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# snapshot + check is rig's `make check` asked of a REMOTE machine. rig's check
|
||||
# reports and instructs and never fixes; so does this. rig's check already reads
|
||||
# /proc/meminfo through an override path so its branches can be exercised
|
||||
# "against another machine's real numbers" — this is that idea taken the rest
|
||||
# of the way: collect the other machine's numbers ONCE, over one announced ssh
|
||||
# session, then answer every question from the saved copy. The analysis can
|
||||
# then be re-run, tested against fixtures, and compared across days, and it
|
||||
# never needs the box to be reachable — which, on a box that is failing, it
|
||||
# may not be.
|
||||
|
||||
set -euo pipefail
|
||||
# Kept before the cd: a snapshot path given on the command line is relative to
|
||||
# where the user stood, not to ctrl/.
|
||||
CALLER_PWD="$PWD"
|
||||
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."
|
||||
}
|
||||
|
||||
# Overridable so selftest can point check at an empty root. snapshot still
|
||||
# refuses to write anywhere git does not confirm is ignored — a root outside
|
||||
# the repo is refused, not honoured.
|
||||
SNAP_ROOT="${SNAP_ROOT:-./.snapshots}"
|
||||
|
||||
snapshot() {
|
||||
local run="" admin=""
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--run) run=1 ;;
|
||||
--admin) admin=1 ;;
|
||||
*) echo "snapshot: unknown argument '$a'" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$run" ]; then
|
||||
echo "would send, over 'ssh $HOST bash -s', exactly this file — ctrl/remote/probe.sh:"
|
||||
echo
|
||||
sed 's/^/ | /' ./remote/probe.sh
|
||||
if [ -n "$admin" ]; then
|
||||
echo
|
||||
echo "and, over 'ssh $HOST_ADMIN bash -s', exactly this — ctrl/remote/probe-admin.sh:"
|
||||
echo
|
||||
sed 's/^/ | /' ./remote/probe-admin.sh
|
||||
fi
|
||||
echo
|
||||
echo "read-only, and NOT run. What you read above is the file that is sent;"
|
||||
echo "there is no second copy. To run it:"
|
||||
echo " bash ctrl/host.sh snapshot --run${admin:+ --admin}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local dir; dir="$SNAP_ROOT/$ESTATE/$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
assert_ignored "$dir/raw" # before anything is written, not after
|
||||
mkdir -p "$dir"
|
||||
|
||||
# Interrupted (Ctrl-C on a slow box), keep what arrived: split it and mark
|
||||
# it partial. Unsplit, it was invisible to check — and newer than the last
|
||||
# good snapshot, so it hid that one too.
|
||||
trap 'echo " ! interrupted — keeping the partial snapshot" >&2;
|
||||
echo "interrupted" > "$dir/PARTIAL"; split_sections "$dir/raw" "$dir";
|
||||
[ -f "$dir/raw-admin" ] && split_sections "$dir/raw-admin" "$dir"; exit 130' INT
|
||||
echo "ssh $HOST bash -s < ctrl/remote/probe.sh"
|
||||
if ! ssh -o BatchMode=yes "$HOST" bash -s < ./remote/probe.sh > "$dir/raw" 2> "$dir/stderr"; then
|
||||
echo " ! ssh $HOST exited non-zero — output so far is kept in $dir" >&2
|
||||
sed 's/^/ /' "$dir/stderr" >&2
|
||||
fi
|
||||
split_sections "$dir/raw" "$dir"
|
||||
|
||||
if [ -n "$admin" ]; then
|
||||
echo "ssh $HOST_ADMIN bash -s < ctrl/remote/probe-admin.sh"
|
||||
ssh -o BatchMode=yes "$HOST_ADMIN" bash -s < ./remote/probe-admin.sh \
|
||||
> "$dir/raw-admin" 2>> "$dir/stderr" || \
|
||||
echo " ! ssh $HOST_ADMIN exited non-zero — see $dir/stderr" >&2
|
||||
split_sections "$dir/raw-admin" "$dir"
|
||||
fi
|
||||
trap - INT
|
||||
echo "saved: $dir"
|
||||
echo
|
||||
check "$dir"
|
||||
}
|
||||
|
||||
# One file per `### name` section. An empty section still gets its file, so
|
||||
# "the probe ran and found nothing" is distinguishable from "it never ran".
|
||||
split_sections() {
|
||||
awk -v d="$2" '/^### /{ f = d "/" $2; printf "" > f; next } f { print > f }' "$1"
|
||||
}
|
||||
|
||||
# docker prints decimal units (kB, MB, GB). Approximate on purpose: these are
|
||||
# for choosing between levers, not for accounting.
|
||||
AWK_MB='function mb(s, v, u) {
|
||||
v = s; u = s; sub(/[A-Za-z]+$/, "", v); sub(/^[0-9.]+/, "", u)
|
||||
if (u == "B") return v / 1e6
|
||||
if (u == "kB" || u == "KB") return v / 1e3
|
||||
if (u == "MB") return v + 0
|
||||
if (u == "GB") return v * 1e3
|
||||
if (u == "TB") return v * 1e6
|
||||
return 0
|
||||
}'
|
||||
|
||||
check() {
|
||||
local f="${1:-}"
|
||||
if [ -z "$f" ]; then
|
||||
# `|| true`: with no snapshots, ls fails, pipefail fails the pipeline and
|
||||
# set -e exits silently — on exactly the path that must explain itself.
|
||||
f=$(ls -1d "$SNAP_ROOT/$ESTATE"/*/meta 2>/dev/null | sort | tail -1) || true
|
||||
f=${f%/meta}
|
||||
fi
|
||||
# Exit 1 here, unlike every other report in berth: with no snapshot there is
|
||||
# no report, and a check that prints nothing alarming because it looked at
|
||||
# nothing is the vacuous pass selftest exists to catch.
|
||||
case "$f" in /*|"") ;; *) [ -d "$f" ] || f="$CALLER_PWD/$f" ;; esac
|
||||
if [ -z "$f" ] || [ ! -f "$f/meta" ]; then
|
||||
echo "no snapshot for estate '$ESTATE'. Take one — read-only, printed before it runs:"
|
||||
echo " make host snapshot"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local taken box age_min
|
||||
taken=$(sed -n 1p "$f/meta"); box=$(sed -n 2p "$f/meta")
|
||||
age_min=$(( ( $(date -u +%s) - $(date -u -d "$taken" +%s 2>/dev/null || date -u +%s) ) / 60 ))
|
||||
echo "snapshot $f"
|
||||
echo " $box, taken $taken"
|
||||
[ -f "$f/PARTIAL" ] && echo " ! PARTIAL — interrupted; sections after the cut are missing, not empty"
|
||||
if [ "$age_min" -ge 60 ]; then
|
||||
echo " ! $((age_min / 60))h old — this is how the box WAS. Re-snapshot before acting on it."
|
||||
fi
|
||||
|
||||
# ── disk ────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo "disk"
|
||||
local rows; rows=$(awk 'NR > 1 && NF >= 6 { p = $5; sub(/%/, "", p); printf "%s\t%d\t%d\t%d\n", $6, p, $4, $2 }' "$f/df")
|
||||
local full=0 mnt pct avail total
|
||||
while IFS=$'\t' read -r mnt pct avail total; do
|
||||
[ -z "$mnt" ] && continue
|
||||
local mark=" "; [ "$pct" -ge 85 ] && mark=" !"
|
||||
printf "%s %-22s %3d%% %7d MB free of %7d MB\n" "$mark" "$mnt" "$pct" "$avail" "$total"
|
||||
[ "$pct" -ge 95 ] && full=1
|
||||
done <<< "$rows"
|
||||
awk 'NR > 1 && NF >= 6 { p = $5; sub(/%/, "", p); if (p + 0 >= 85) printf " ! inodes %s at %s%% — space can be free and writes still fail\n", $6, $5 }' "$f/df_inodes"
|
||||
|
||||
# The filesystem docker writes to: the longest mount point that prefixes
|
||||
# /var/lib/docker. Usually /, but a separate /var would otherwise be missed.
|
||||
local dmnt dpct davail dtotal
|
||||
IFS=$'\t' read -r dmnt dpct davail dtotal < <(
|
||||
awk -F'\t' '{ m = $1; if (index("/var/lib/docker", m) == 1 && (m == "/" || substr("/var/lib/docker", length(m) + 1, 1) == "/") && length(m) > best) { best = length(m); row = $0 } } END { print row }' <<< "$rows")
|
||||
|
||||
if [ "$full" = 1 ]; then
|
||||
echo
|
||||
echo " A full disk fails WRITES, not reads — so it rarely presents as 'disk':"
|
||||
echo " pages render logged-out but 500 logged-in (sessions, DB writes)"
|
||||
echo " databases crash-loop on start (they must write to start)"
|
||||
echo " pushes and uploads die partway through"
|
||||
fi
|
||||
|
||||
# ── memory — rig's check.sh mb_of, recomputed here (✖ B8), pointed at the box's /proc/meminfo ──
|
||||
echo
|
||||
echo "memory"
|
||||
mb_of() {
|
||||
awk -v k="$1:" '$1 == k { printf "%d", $2 / 1024; found = 1 } END { if (!found) printf "0" }' "$f/meminfo"
|
||||
}
|
||||
local mtotal mavail swap_used
|
||||
mtotal=$(mb_of MemTotal); mavail=$(mb_of MemAvailable)
|
||||
swap_used=$(( $(mb_of SwapTotal) - $(mb_of SwapFree) ))
|
||||
printf " available %d MB of %d MB\n" "$mavail" "$mtotal"
|
||||
[ "$mtotal" -gt 0 ] && [ $(( mavail * 100 / mtotal )) -lt 10 ] && \
|
||||
echo " ! under 10% available — expect the OOM killer before anything says 'memory'"
|
||||
[ "$swap_used" -gt 0 ] && printf " ! %d MB in swap\n" "$swap_used"
|
||||
|
||||
# ── containers ──────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo "containers"
|
||||
awk -F'\t' 'NF >= 3 { n++; if ($2 == "running") r++; else printf " ! %-26s %-11s %s\n", $1, $2, $3 }
|
||||
END { printf " %d of %d running\n", r, n }' "$f/containers"
|
||||
awk -F'\t' 'NF >= 3 && $3 + 0 > 0 { n = $2; sub(/^\//, "", n); printf "%d\t%s\n", $3, n }' "$f/container_config" \
|
||||
| sort -rn | head -5 | awk -F'\t' '{ printf " restarted %4d× %s\n", $1, $2 }'
|
||||
local unbounded
|
||||
unbounded=$(awk -F'\t' 'NF >= 6 && $5 == "json-file" && ($6 == "" || $6 == "<no value>") { n = $2; sub(/^\//, "", n); printf "%s ", n }' "$f/container_config")
|
||||
if [ -n "$unbounded" ]; then
|
||||
echo " ! json-file logs with NO max-size — each grows until the disk is full:"
|
||||
echo "$unbounded" | fold -s -w 70 | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
# ── what would free space, cheapest first ───────────────────────────────
|
||||
echo
|
||||
echo "space — what would free it, and what it costs (≈MB; printed, never run)"
|
||||
local dfrows; dfrows=$(awk "$AWK_MB"'
|
||||
NR > 1 && NF >= 5 {
|
||||
line = $0; gsub(/\([0-9]+%\)/, "", line); n = split(line, a, " ")
|
||||
t = a[1]; for (i = 2; i <= n - 4; i++) t = t " " a[i]
|
||||
printf "%s\t%d\t%d\n", t, mb(a[n - 1]), mb(a[n])
|
||||
}' "$f/docker_df")
|
||||
rec() { awk -F'\t' -v t="$1" '$1 == t { print $3 }' <<< "$dfrows"; }
|
||||
local r_cache r_img r_ctr r_vol j_mb logs_mb=""
|
||||
r_cache=$(rec "Build Cache"); r_img=$(rec "Images"); r_ctr=$(rec "Containers"); r_vol=$(rec "Local Volumes")
|
||||
j_mb=$(awk '/take up/ { for (i = 1; i <= NF; i++) if ($i ~ /^[0-9.]+[KMGT]$/) { v = $i; u = substr(v, length(v)); sub(/[KMGT]$/, "", v); m = (u == "K" ? v / 1024 : u == "M" ? v : u == "G" ? v * 1024 : v * 1048576); printf "%d", m } }' "$f/journal" 2>/dev/null)
|
||||
|
||||
printf " %6s build cache docker builder prune free — nothing running uses it\n" "${r_cache:-?}"
|
||||
printf " %6s unused images docker image prune -a next deploy re-pulls or rebuilds\n" "${r_img:-?}"
|
||||
printf " %6s stopped ctrs docker container prune they cannot be 'start'ed again\n" "${r_ctr:-?}"
|
||||
printf " %6s host journal sudo journalctl --vacuum-size=200M older host logs are gone\n" "${j_mb:-?}"
|
||||
if [ -f "$f/admin_container_logs" ]; then
|
||||
logs_mb=$(awk '$1 ~ /^[0-9]+$/ { s += $1 } END { print s + 0 }' "$f/admin_container_logs")
|
||||
printf " %6s container logs sudo truncate -s 0 <log> RELIEF, not cure: they refill\n" "$logs_mb"
|
||||
awk -F'\t' 'NR == FNR { id[substr($1, 1, 12)] = $2; next }
|
||||
$1 ~ /^[0-9]+$/ && $1 >= 50 { n = $2; sub(/.*containers\//, "", n); k = substr(n, 1, 12); nm = (k in id ? id[k] : k); sub(/^\//, "", nm); printf " %6d %s\n", $1, nm }' \
|
||||
"$f/container_config" "$f/admin_container_logs"
|
||||
else
|
||||
echo " ? container logs not sized: /var/lib/docker is root-only"
|
||||
echo " — 'make host snapshot' with --admin sizes them"
|
||||
fi
|
||||
printf " %6s unused VOLUMES (no command) DATA. Unreferenced is not unwanted —\n" "${r_vol:-?}"
|
||||
echo " a dangling volume can be the only copy. Decide per volume."
|
||||
if [ -s "$f/registry_repos" ] && ! grep -q '^(not found' "$f/registry_repos"; then
|
||||
local blobs; blobs=$(awk -F'\t' '$1 == "blobs_mb" { print $2 }' "$f/registry_repos")
|
||||
printf " %6s registry blobs registry garbage-collect a DECISION — see registry below\n" "${blobs:-?}"
|
||||
fi
|
||||
|
||||
# rig's check tells apart two failures with opposite fixes ("stopping that
|
||||
# is what helps — a bigger VM would not"). The disk has the same pair.
|
||||
if [ -n "$dmnt" ] && [ "${dtotal:-0}" -gt 0 ]; then
|
||||
local deficit safe
|
||||
deficit=$(( (dtotal - davail) - dtotal * 80 / 100 ))
|
||||
safe=$(( ${r_cache:-0} + ${r_img:-0} + ${r_ctr:-0} + ${j_mb:-0} + ${logs_mb:-0} ))
|
||||
echo
|
||||
if [ "$deficit" -le 0 ]; then
|
||||
printf " %s is under 80%% — no deficit.\n" "$dmnt"
|
||||
elif [ "$safe" -lt "$deficit" ] && [ -z "$logs_mb" ]; then
|
||||
# Container logs are unsized without --admin. Counting that as 0 once
|
||||
# printed "Pruning will NOT fix this" on a 15 MB margin — a verdict
|
||||
# resting entirely on a number nobody measured.
|
||||
printf " %s needs ≈%d MB to reach 80%%; ≈%d MB is reclaimable, NOT counting container logs.\n" "$dmnt" "$deficit" "$safe"
|
||||
echo " Undetermined. Logs were not sized, and they are the one lever that can"
|
||||
echo " flip this verdict. Size them first: bash ctrl/host.sh snapshot --run --admin"
|
||||
elif [ "$safe" -ge "$deficit" ]; then
|
||||
printf " %s needs ≈%d MB to reach 80%%; ≈%d MB is reclaimable without touching data.\n" "$dmnt" "$deficit" "$safe"
|
||||
echo " Pruning is enough. A bigger disk would not be needed yet —"
|
||||
echo " and if it refills, the cause is unbounded logs or builds, not size."
|
||||
else
|
||||
printf " %s needs ≈%d MB to reach 80%%; only ≈%d MB is reclaimable without touching data.\n" "$dmnt" "$deficit" "$safe"
|
||||
echo " Pruning will NOT fix this. The rest is data — see the biggest directories."
|
||||
echo " Grow the volume (EBS resizes online, then growpart + resize2fs), or move data off."
|
||||
fi
|
||||
fi
|
||||
|
||||
# What was measured, against what is used. The gap is not a finding about
|
||||
# WHAT fills it — only that the unprivileged probe could not see it.
|
||||
# Measured against used. Two estimates were wrong before this (2026-09-16):
|
||||
# "used − docker − \$HOME" forgot the OS, and called 3 GB of /usr invisible.
|
||||
# du / already walks the OS; only root-only trees are missing from it.
|
||||
if [ -n "$dmnt" ] && [ -z "$logs_mb" ]; then
|
||||
local seen gap
|
||||
seen=$(awk -F'\t' '$2 == "/" { print $1 + 0 }' "$f/du_root" 2>/dev/null)
|
||||
if [ -n "$seen" ]; then
|
||||
gap=$(( (dtotal - davail) - seen ))
|
||||
if [ "$gap" -gt 512 ]; then
|
||||
echo
|
||||
printf " ≈%d MB used on %s is root-only — du / could not read it.\n" "$gap" "$dmnt"
|
||||
echo " Mostly /var/lib/docker, container logs included. --admin measures it."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── where the space is ──────────────────────────────────────────────────
|
||||
echo
|
||||
echo "biggest directories (MB; unprivileged, so root-only trees are absent)"
|
||||
awk -F'\t' 'NR <= 8 { printf " %7d %s\n", $1, $2 }' "$f/du_root" 2>/dev/null
|
||||
if [ -f "$f/admin_docker_dirs" ]; then
|
||||
echo " — /var/lib/docker (root):"
|
||||
awk -F'\t' 'NR <= 4 && $1 ~ /^[0-9]+$/ { printf " %7d %s\n", $1, $2 }' "$f/admin_docker_dirs"
|
||||
fi
|
||||
echo " — under \$HOME:"
|
||||
awk -F'\t' 'NR >= 2 && NR <= 8 { printf " %7d %s\n", $1, $2 }' "$f/du_home" 2>/dev/null
|
||||
|
||||
# ── registry ────────────────────────────────────────────────────────────
|
||||
if [ -s "$f/registry_repos" ] && ! grep -q '^(not found' "$f/registry_repos"; then
|
||||
echo
|
||||
echo "registry (answers /v2/ anonymously — so read this list before collecting garbage)"
|
||||
awk -F'\t' '$1 != "blobs_mb" { printf " %s %3s tag(s) %s\n", $1, $2, $3 }' "$f/registry_repos"
|
||||
echo " A repository nobody recognises here is a finding, not clutter: with"
|
||||
echo " anonymous push, it may be someone else's image in your deploy path."
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "reports, never fixes. Every command above is printed; none was run."
|
||||
}
|
||||
|
||||
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."
|
||||
;;
|
||||
snapshot) shift; snapshot "$@" ;;
|
||||
check) shift; check "$@" ;;
|
||||
*) echo "usage: $0 [status|ports|services|snapshot [--run] [--admin]|check [<dir>]]" >&2; exit 1 ;;
|
||||
esac
|
||||
94
berth/ctrl/lib/config.sh
Normal file
94
berth/ctrl/lib/config.sh
Normal 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}"
|
||||
}
|
||||
160
berth/ctrl/lib/estate.sh
Normal file
160
berth/ctrl/lib/estate.sh
Normal file
@@ -0,0 +1,160 @@
|
||||
# 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
|
||||
}
|
||||
|
||||
# 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. Used before writing key
|
||||
# material (vpn.sh) and machine snapshots (host.sh) — refuse, don't hope.
|
||||
assert_ignored() {
|
||||
local path="$1"
|
||||
if ! git check-ignore -q "$path" 2>/dev/null; then
|
||||
echo "REFUSING: '$path' is not gitignored." >&2
|
||||
echo " Writing there would stage it on the next 'git add'." >&2
|
||||
echo " Verify with: git check-ignore -v $path" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
78
berth/ctrl/ports.sh
Normal file
78
berth/ctrl/ports.sh
Normal 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
33
berth/ctrl/registry.sh
Normal 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
|
||||
17
berth/ctrl/remote/probe-admin.sh
Normal file
17
berth/ctrl/remote/probe-admin.sh
Normal file
@@ -0,0 +1,17 @@
|
||||
# Read-only probe of what only root can size (`ssh $HOST_ADMIN`, sudo).
|
||||
#
|
||||
# Separate from probe.sh on purpose: INDEX §7 — the admin alias only where sudo
|
||||
# is genuinely required. It is here: /var/lib/docker is root-only, so without
|
||||
# this a container's json log — the classic way a small disk fills — has no size.
|
||||
# Sizes only. Nothing is read out of any file.
|
||||
set -u
|
||||
section() { printf '\n### %s\n' "$1"; }
|
||||
|
||||
section admin_docker_dirs
|
||||
sudo -n timeout 60 du -xm --max-depth=1 /var/lib/docker 2>&1 | sort -rn | head -20
|
||||
|
||||
section admin_container_logs
|
||||
sudo -n timeout 60 sh -c 'du -m /var/lib/docker/containers/*/*-json.log 2>/dev/null' | sort -rn | head -20
|
||||
|
||||
section admin_volumes
|
||||
sudo -n timeout 60 du -xm --max-depth=1 /var/lib/docker/volumes 2>&1 | sort -rn | head -20
|
||||
79
berth/ctrl/remote/probe.sh
Normal file
79
berth/ctrl/remote/probe.sh
Normal file
@@ -0,0 +1,79 @@
|
||||
# Read-only probe of the box, as the unprivileged user (`ssh $HOST`).
|
||||
#
|
||||
# host.sh sends THIS FILE, verbatim, over `ssh $HOST bash -s`, and prints this
|
||||
# file, verbatim, when asked what it would run. The commands you read are the
|
||||
# commands that run — there is no second copy to drift from.
|
||||
#
|
||||
# Never read here, because a snapshot is written to disk on another machine:
|
||||
# - container environments (bare `docker inspect` prints Env — every
|
||||
# inspect below names the fields it prints)
|
||||
# - compose files and .env (inline secrets)
|
||||
# - container logs (tokens and credentials leak into logs)
|
||||
# No sudo. What needs root is probe-admin.sh, a separately announced batch.
|
||||
set -u
|
||||
section() { printf '\n### %s\n' "$1"; }
|
||||
# Every step that walks a tree is bounded. `du /` takes ~20s on this box and
|
||||
# prints nothing until it finishes (its output goes through sort), so a slow or
|
||||
# sick disk looks exactly like a hang. A step that times out says so and the
|
||||
# probe moves on: a partial answer beats none.
|
||||
bounded() { local t="$1"; shift; timeout "$t" "$@"; rc=$?; [ $rc = 124 ] && echo "(timed out after ${t}s: $*)"; return 0; }
|
||||
REAL='-x tmpfs -x devtmpfs -x overlay -x squashfs -x efivarfs'
|
||||
|
||||
section meta
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ
|
||||
hostname
|
||||
uname -r
|
||||
uptime
|
||||
|
||||
section df
|
||||
df -P -B1M $REAL 2>/dev/null
|
||||
|
||||
section df_inodes
|
||||
df -P -i $REAL 2>/dev/null
|
||||
|
||||
section meminfo
|
||||
cat /proc/meminfo
|
||||
|
||||
section containers
|
||||
docker ps -a --format '{{.Names}}\t{{.State}}\t{{.Status}}\t{{.Image}}' 2>&1
|
||||
|
||||
section container_config
|
||||
ids=$(docker ps -aq 2>/dev/null)
|
||||
[ -n "$ids" ] && docker inspect --format '{{.Id}} {{.Name}} {{.RestartCount}} {{.HostConfig.RestartPolicy.Name}} {{.HostConfig.LogConfig.Type}} {{index .HostConfig.LogConfig.Config "max-size"}} {{index .Config.Labels "com.docker.compose.project.working_dir"}}' $ids 2>&1
|
||||
|
||||
section docker_df
|
||||
docker system df 2>&1
|
||||
|
||||
section docker_df_verbose
|
||||
bounded 30 docker system df -v 2>&1
|
||||
|
||||
section daemon_json
|
||||
cat /etc/docker/daemon.json 2>/dev/null || echo '(absent)'
|
||||
|
||||
section journal
|
||||
journalctl --disk-usage 2>&1
|
||||
|
||||
section du_home
|
||||
bounded 30 du -xm --max-depth=3 "$HOME" 2>/dev/null | sort -rn | head -40
|
||||
|
||||
section du_root
|
||||
# Unprivileged, so /var/lib/docker is absent — probe-admin.sh sizes that. What it
|
||||
# does see matters anyway: the OS under /usr, and /var/log (779 MB, 2026-09-16).
|
||||
bounded 90 du -xm --max-depth=2 / 2>/dev/null | sort -rn | head -40
|
||||
|
||||
section registry_repos
|
||||
# The registry answers /v2/ anonymously (curl -> 200, 2026-09-14), so what is
|
||||
# stored in it is a question in its own right, not bookkeeping.
|
||||
wd=$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project.working_dir"}}' registry 2>/dev/null)
|
||||
r="${wd:-$HOME/ppl/gateway}/registry-data/docker/registry/v2/repositories"
|
||||
if [ -d "$r" ]; then
|
||||
timeout 30 find "$r" -type d -name _manifests -prune -print 2>/dev/null | while read -r m; do
|
||||
repo=${m%/_manifests}; repo=${repo#"$r"/}
|
||||
newest=$(find "$m" -type f -printf '%TY-%Tm-%Td\n' 2>/dev/null | sort | tail -1)
|
||||
tags=$(ls "$m/tags" 2>/dev/null | wc -l)
|
||||
printf '%s\t%s\t%s\n' "${newest:-?}" "$tags" "$repo"
|
||||
done | sort
|
||||
printf 'blobs_mb\t%s\n' "$(du -sm "${r%/repositories}/blobs" 2>/dev/null | cut -f1)"
|
||||
else
|
||||
echo "(not found at $r)"
|
||||
fi
|
||||
32
berth/ctrl/render/nginx-proxy.tmpl
Normal file
32
berth/ctrl/render/nginx-proxy.tmpl
Normal 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;
|
||||
}
|
||||
}
|
||||
23
berth/ctrl/render/nginx-static.tmpl
Normal file
23
berth/ctrl/render/nginx-static.tmpl
Normal 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;
|
||||
}
|
||||
}
|
||||
32
berth/ctrl/render/nginx-upstream.tmpl
Normal file
32
berth/ctrl/render/nginx-upstream.tmpl
Normal 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;
|
||||
}
|
||||
}
|
||||
261
berth/ctrl/selftest.sh
Normal file
261
berth/ctrl/selftest.sh
Normal file
@@ -0,0 +1,261 @@
|
||||
#!/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 "host snapshot + check — rig's check, asked of a remote box"
|
||||
|
||||
# Announce-first made mechanical: without --run nothing reaches ssh or disk.
|
||||
before="$(ls -1 .snapshots 2>/dev/null | wc -l)"
|
||||
out="$(bash ./host.sh snapshot 2>&1)"
|
||||
check "snapshot without --run says it ran nothing" "1" "$(grep -c 'and NOT run' <<< "$out")"
|
||||
check "snapshot without --run writes nothing" "$before" "$(ls -1 .snapshots 2>/dev/null | wc -l)"
|
||||
# The printed probe IS the sent probe — no second copy to drift from.
|
||||
check "what is announced is byte-for-byte what is sent" "same" \
|
||||
"$(diff -q <(sed -n 's/^ | //p; s/^ |$//p' <<< "$out" | sed -n "1,$(wc -l < remote/probe.sh)p") remote/probe.sh >/dev/null && echo same || echo differs)"
|
||||
check "snapshots are gitignored" "yes" \
|
||||
"$(cd .. && git check-ignore -q ctrl/.snapshots/x/raw && echo yes || echo no)"
|
||||
|
||||
# A snapshot is written to another machine's disk. Environments, compose files
|
||||
# and logs carry secrets, so the probes must never read them. Comments excluded:
|
||||
# probe.sh's header names exactly these things in order to forbid them.
|
||||
check "the probes never read env, compose, or logs" "0" \
|
||||
"$(grep -hvE '^\s*#' remote/probe.sh remote/probe-admin.sh \
|
||||
| grep -cE 'docker logs|docker inspect [^-]|docker inspect \$|\.env\b|compose\.ya?ml|printenv|/environ')"
|
||||
|
||||
hfix="$(mktemp -d)"
|
||||
T="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
printf '%s\nfixture\n' "$T" > "$hfix/meta"
|
||||
printf 'Filesystem 1048576-blocks Used Available Capacity Mounted\n/dev/x 15731 15714 0 100%% /\n' > "$hfix/df"
|
||||
printf 'Filesystem Inodes IUsed IFree IUse%% Mounted\n/dev/x 100 10 90 10%% /\n' > "$hfix/df_inodes"
|
||||
printf 'MemTotal: 2000000 kB\nMemAvailable: 1000000 kB\nSwapTotal: 0 kB\nSwapFree: 0 kB\n' > "$hfix/meminfo"
|
||||
printf 'mongo\trestarting\tRestarting (1) 2s ago\tmongo:6\n' > "$hfix/containers"
|
||||
printf 'aaaaaaaaaaaa\t/mongo\t99\talways\tjson-file\t<no value>\t/x\n' > "$hfix/container_config"
|
||||
printf 'TYPE TOTAL ACTIVE SIZE RECLAIMABLE\nImages 3 1 5GB 4GB (80%%)\nBuild Cache 1 0 1GB 1GB\n' > "$hfix/docker_df"
|
||||
: > "$hfix/journal"; : > "$hfix/du_root"; : > "$hfix/du_home"
|
||||
hout="$(bash ./host.sh check "$hfix" 2>&1)"
|
||||
check "a full disk is flagged" "1" "$(grep -c '^ ! / ' <<< "$hout")"
|
||||
check "a crash-looping container is flagged" "1" "$(grep -c '! mongo .*restarting' <<< "$hout")"
|
||||
check "an unbounded json log is flagged" "1" "$(grep -c 'NO max-size' <<< "$hout")"
|
||||
check "reclaimable ≥ deficit → pruning is enough" "1" "$(grep -c 'Pruning is enough' <<< "$hout")"
|
||||
printf 'TYPE TOTAL ACTIVE SIZE RECLAIMABLE\nImages 3 3 5GB 0B (0%%)\n' > "$hfix/docker_df"
|
||||
# Unknown is not zero. Without --admin, container logs are unsized, and they can
|
||||
# flip the verdict — 2026-09-16 printed "will NOT fix" on a 15 MB margin that
|
||||
# rested on counting them as 0. Unsized → undetermined, never a verdict.
|
||||
check "unsized logs + a shortfall → undetermined, not a verdict" "1 0" \
|
||||
"$(o="$(bash ./host.sh check "$hfix" 2>&1)"; echo "$(grep -c Undetermined <<< "$o") $(grep -c 'will NOT fix' <<< "$o")")"
|
||||
printf '5000\t/var/lib/docker/containers/aaaaaaaaaaaa/aaaaaaaaaaaa-json.log\n' > "$hfix/admin_container_logs"
|
||||
check "sized logs map back to container names" "1" \
|
||||
"$(bash ./host.sh check "$hfix" 2>&1 | grep -cE '^ +5000 mongo$')"
|
||||
: > "$hfix/admin_container_logs"
|
||||
check "measured and still short → pruning will NOT fix it" "1" \
|
||||
"$(bash ./host.sh check "$hfix" 2>&1 | grep -c 'Pruning will NOT fix')"
|
||||
rm -rf "$hfix"
|
||||
check "no snapshot is exit 1, not a silent pass" "1" \
|
||||
"$(empty="$(mktemp -d)"; SNAP_ROOT="$empty" bash ./host.sh check >/dev/null 2>&1; echo $?; rmdir "$empty")"
|
||||
|
||||
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
183
berth/ctrl/services.sh
Normal 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
11
berth/ctrl/versions.env
Normal 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
|
||||
468
berth/ctrl/vpn.sh
Normal file
468
berth/ctrl/vpn.sh
Normal file
@@ -0,0 +1,468 @@
|
||||
#!/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
|
||||
}
|
||||
|
||||
# assert_ignored lives in lib/estate.sh — host.sh's snapshots need it too.
|
||||
|
||||
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
326
berth/estate/mcrn.json
Normal 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."
|
||||
}
|
||||
]
|
||||
}
|
||||
184
build.py
184
build.py
@@ -23,7 +23,6 @@ Generated structure for managed rooms:
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
@@ -559,148 +558,6 @@ def _append_cabinet_env(output_dir: Path, cabinets: list[dict]):
|
||||
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):
|
||||
"""Build soleprint folder with core + room config merged."""
|
||||
soleprint = SPR_ROOT / "soleprint"
|
||||
@@ -738,11 +595,6 @@ def build_soleprint(output_dir: Path, room: str):
|
||||
log.info("Composing cabinets...")
|
||||
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
|
||||
log.info("Generating models...")
|
||||
if not generate_models(output_dir, room):
|
||||
@@ -799,33 +651,6 @@ def build_models_only():
|
||||
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():
|
||||
parser = argparse.ArgumentParser(description="Soleprint Build Tool")
|
||||
|
||||
@@ -833,17 +658,10 @@ def main():
|
||||
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("--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()
|
||||
|
||||
if args.plexuses:
|
||||
build_plexuses_only(args.cfg or "standalone")
|
||||
elif args.models:
|
||||
if args.models:
|
||||
build_models_only()
|
||||
elif args.all:
|
||||
build(SPR_ROOT / "gen" / "standalone", None)
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"name": "bundle"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,16 +6,51 @@
|
||||
# ./ctrl/cluster.sh down # delete it (drops every room's namespace)
|
||||
# ./ctrl/cluster.sh status # what's running on it
|
||||
#
|
||||
# One target, one script — the variants live here. The kind-*.sh files stay
|
||||
# exactly as they are and remain runnable on their own; this only dispatches.
|
||||
# spr depends on rig, never the other way round. Building and deleting a cluster
|
||||
# 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
|
||||
|
||||
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
|
||||
up) exec "$SCRIPT_DIR/kind-up.sh" ;;
|
||||
down) exec "$SCRIPT_DIR/kind-down.sh" ;;
|
||||
status) exec "$SCRIPT_DIR/kind-status.sh" ;;
|
||||
up)
|
||||
rig up
|
||||
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 "Usage: cluster.sh [up|down|status]" >&2
|
||||
|
||||
32
ctrl/dist.sh
32
ctrl/dist.sh
@@ -2,30 +2,36 @@
|
||||
# Compile the plexus UIs to distributable files — this repo's `vite build`.
|
||||
#
|
||||
# Usage:
|
||||
# ./ctrl/dist.sh # standalone
|
||||
# ./ctrl/dist.sh sample # a named room
|
||||
# ./ctrl/dist.sh # every plexus -> gen/plexuses/<name>/index.html
|
||||
# ./ctrl/dist.sh bundle # one, by name
|
||||
# ./ctrl/dist.sh path/to/ui.toml # every [[plexus]] a run file lists
|
||||
# ./ctrl/dist.sh bundle --dest /tmp/out --delivery vendored --notice none
|
||||
#
|
||||
# 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.
|
||||
# It needs nothing built first: a plexus is its own directory under
|
||||
# soleprint/artery/plexuses/ (or wherever a run file points), and the theme it
|
||||
# carries is cut to what the page reads. See soleprint/common/slice/plexus.py.
|
||||
set -e
|
||||
|
||||
CALLER_DIR="$PWD"
|
||||
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
|
||||
args=()
|
||||
for a in "$@"; do
|
||||
# A bare word that names an existing path is a path the caller typed.
|
||||
if [[ "$a" != -* && -e "$CALLER_DIR/$a" ]]; then
|
||||
args+=("$(cd "$CALLER_DIR" && realpath -m -- "$a")")
|
||||
else
|
||||
args+=("$a")
|
||||
fi
|
||||
done
|
||||
|
||||
exec "$PYTHON" build.py --plexuses --cfg "$ROOM"
|
||||
cd "$ROOT_DIR/soleprint"
|
||||
exec "$PYTHON" -m common.slice plexus "${args[@]}"
|
||||
|
||||
@@ -3,9 +3,15 @@ apiVersion: kind.x-k8s.io/v1alpha4
|
||||
# Single shared cluster for all soleprint rooms.
|
||||
# Each room deploys into its own namespace; gateway Services pick a
|
||||
# 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:
|
||||
- role: control-plane
|
||||
image: ${NODE_IMAGE}
|
||||
extraPortMappings:
|
||||
# Room gateway NodePorts (one per active room).
|
||||
- {containerPort: 30080, hostPort: 30080, protocol: TCP}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
10
ctrl/spr.py
10
ctrl/spr.py
@@ -73,7 +73,15 @@ def get_version(comp_type, source):
|
||||
for line in (source / "pyproject.toml").read_text().splitlines():
|
||||
if line.strip().startswith("version"):
|
||||
return line.split('"')[1]
|
||||
except FileNotFoundError:
|
||||
elif comp_type == "python":
|
||||
# A plain package: no package.json, no pyproject.toml, just
|
||||
# __version__ in __init__.py. station/tools/common is one -- it is
|
||||
# imported and vendored, never installed, so giving it packaging
|
||||
# metadata to satisfy this function would be the tail wagging the dog.
|
||||
for line in (source / "__init__.py").read_text().splitlines():
|
||||
if line.strip().startswith("__version__"):
|
||||
return line.split('"')[1] if '"' in line else line.split("'")[1]
|
||||
except (FileNotFoundError, IndexError):
|
||||
pass
|
||||
return "?"
|
||||
|
||||
|
||||
80
ctrl/theme.sh
Executable file
80
ctrl/theme.sh
Executable 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
|
||||
45
ctrl/ui.sh
Executable file
45
ctrl/ui.sh
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# Slices: the part of spr's UI one consumer uses, generated as readable source.
|
||||
#
|
||||
# Usage:
|
||||
# ./ctrl/ui.sh list # what can be sliced, and what each piece pulls in
|
||||
# ./ctrl/ui.sh scan APP_DIR... # what an app uses; its framework copy against spr
|
||||
# ./ctrl/ui.sh slice FILE [--only N] [--list] [--force]
|
||||
# ./ctrl/ui.sh check FILE [--only N] # writes nothing; exit 1 if anything is stale
|
||||
# ./ctrl/ui.sh reference [--check|--spots|--scaffold] # the ui-reference page of /docs, from common/ui/src
|
||||
# ./ctrl/ui.sh selftest
|
||||
#
|
||||
# FILE is a run file (see soleprint/common/slice/ui.example.toml). It lives beside
|
||||
# the consumer it describes, never in spr, and is named relative to where you
|
||||
# stand — resolved here before the cd below would change what it means.
|
||||
#
|
||||
# `delivery = "dependency"` is for own projects: spr's names stay and a .spr stamp
|
||||
# says where the copy came from. `delivery = "vendored"` renames through a mapping
|
||||
# profile, drops the headers, writes no stamp, scrubs every spr word, and will
|
||||
# not run without an explicit `notice`.
|
||||
#
|
||||
# This is not ./ctrl/theme.sh: that bakes parts into single pages inside spr.
|
||||
# This writes a slice into a consumer, and checks it for drift both ways.
|
||||
set -e
|
||||
|
||||
CALLER_DIR="$PWD"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
PYTHON="${PYTHON:-python3}"
|
||||
|
||||
# The first word is the command, never a path — `docs` is also a directory at the
|
||||
# repo root, and resolving it turned the command into /…/docs.
|
||||
args=("${1:-}")
|
||||
shift || true
|
||||
for a in "$@"; do
|
||||
# A bare word that names an existing path is a path the caller typed.
|
||||
if [[ "$a" != -* && -e "$CALLER_DIR/$a" ]]; then
|
||||
args+=("$(cd "$CALLER_DIR" && realpath -m -- "$a")")
|
||||
else
|
||||
args+=("$a")
|
||||
fi
|
||||
done
|
||||
|
||||
cd "$ROOT_DIR/soleprint"
|
||||
exec "$PYTHON" -m common.slice "${args[@]}"
|
||||
7
docs/data/.ui-reference.generated.json
Normal file
7
docs/data/.ui-reference.generated.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": {
|
||||
"data/en/ui-reference.md": "d010b68b9599f033a3920ef0590c95d10a895e7d4c5b98e2ba5d822c26a32dd4",
|
||||
"data/ui-inventory.json": "7b8895661a3023f3198eb17e5c15101537b4915faf1a9b398abaa49cd575edc4"
|
||||
},
|
||||
"generator": "common.slice reference"
|
||||
}
|
||||
@@ -6,17 +6,17 @@ 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
|
||||
make dist # every plexus
|
||||
make dist bundle # one, by name
|
||||
make dist path/to/ui.toml # the [[plexus]] tables of a run file
|
||||
```
|
||||
|
||||
Output is one `index.html` per plexus under `gen/<room>/plexuses/<name>/`,
|
||||
Output is one `index.html` per plexus under `gen/plexuses/<name>/` (or `--dest`),
|
||||
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.
|
||||
A plexus is built on its own: nothing else has to be built first, and no other
|
||||
configuration has to name it.
|
||||
|
||||
## The constraint that shapes it
|
||||
|
||||
@@ -34,20 +34,22 @@ 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
|
||||
## Choosing what gets built
|
||||
|
||||
A room opts in through `cfg/<room>/data/plexuses.json`, the same shape and the
|
||||
same place as its sibling `data/*.json` files:
|
||||
`make dist` builds every directory under `soleprint/artery/plexuses/` that holds a
|
||||
`plexus.json`. To build with other settings — another theme, a plexus kept
|
||||
outside spr, a different destination — list it in a run file:
|
||||
|
||||
```json
|
||||
{ "items": [ { "name": "bundle" } ] }
|
||||
```toml
|
||||
[[plexus]]
|
||||
name = "bundle"
|
||||
theme = "mcrn"
|
||||
dest = "out/bundle.html"
|
||||
```
|
||||
|
||||
A room may override anything the plexus declares — most usefully the theme:
|
||||
|
||||
```json
|
||||
{ "items": [ { "name": "bundle", "theme": "mcrn" } ] }
|
||||
```
|
||||
`delivery = "vendored"` renames the output through the mapping profile, refuses
|
||||
to write if any spr word survives, and needs a `notice` — the same rules as a
|
||||
vendored slice. See [Export / Compile](#export).
|
||||
|
||||
## Writing one
|
||||
|
||||
@@ -57,17 +59,22 @@ soleprint/artery/plexuses/<name>/
|
||||
app/index.html the template
|
||||
```
|
||||
|
||||
`build.py` fills these placeholders and writes one file:
|
||||
`make dist` fills these placeholders and writes one file:
|
||||
|
||||
| Placeholder | Becomes |
|
||||
| --- | --- |
|
||||
| `%%THEME_CSS%%` | tokens plus every theme, so the switcher has something to switch to |
|
||||
| `%%THEME_CSS%%` | only what the page can use: the custom properties it reads (and what they reach) per theme in `inline_themes`, and the rules that can match something on it |
|
||||
| `%%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.
|
||||
|
||||
A plexus whose `app/` holds a `package.json` is built as a **Vue app** instead:
|
||||
its `@ui` imports are sliced from `common/ui` (the same closure a consumer
|
||||
would get), `vite build` runs in a temporary directory, and the emitted script
|
||||
and stylesheet are inlined into one file. `live/` is the example.
|
||||
|
||||
## The bundle plexus
|
||||
|
||||
The one that ships. It answers "what does a rig installation have at its
|
||||
|
||||
@@ -18,6 +18,21 @@ python ctrl/spr.py publish soleprint-ui <target>
|
||||
|
||||
The target project receives the built files and commits them directly. No npm dependency on spr.
|
||||
|
||||
To give a project only the parts it uses — readable source, not the whole
|
||||
library — slice it instead:
|
||||
|
||||
```bash
|
||||
make ui scan <app-dir> # what it uses, and how its copy compares with spr
|
||||
./ctrl/ui.sh slice <app>/ui.toml # write the slice its run file describes
|
||||
```
|
||||
|
||||
See [Export / Compile](#export) for run files, the two deliveries and drift.
|
||||
|
||||
**Reference:** [UI reference](#ui-reference) — every export's props, models, emits,
|
||||
slots, closure, packages and tokens, generated from the source by `make reference` in
|
||||
`soleprint/common/ui` (which also has `make check`: typecheck, tests, reference current,
|
||||
parts in agreement). Hand-written notes live beside the library in `common/ui/notes/`.
|
||||
|
||||
## soleprint-modelgen
|
||||
|
||||
Model generator. Reads schema definitions and produces model files.
|
||||
|
||||
@@ -19,7 +19,7 @@ make start # run it
|
||||
| Command | Runs | Does |
|
||||
| --- | --- | --- |
|
||||
| `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 dist [NAME\|FILE]` | `ctrl/dist.sh` | compile the plexus UIs to single files — nothing built first |
|
||||
| `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 stop [<room>]` | `ctrl/stop.sh` | stop it |
|
||||
@@ -52,12 +52,9 @@ make component ARGS="publish soleprint-ui /tmp/out --dist"
|
||||
4. **Compose cabinets.** The dependency containers the room declared in
|
||||
`data/cabinets.json` are merged into its `docker-compose.yml`. See
|
||||
[Cabinets](#station-cabinets).
|
||||
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
|
||||
5. **Generate models.** modelgen reads the room's `config.json` and writes
|
||||
`models/pydantic/__init__.py`.
|
||||
7. **Render k8s** (optional). When the room's config enables it,
|
||||
6. **Render k8s** (optional). When the room's config enables it,
|
||||
`soleprint/ctrl/k8s/` writes manifests and lifecycle scripts.
|
||||
|
||||
## What comes out
|
||||
@@ -71,7 +68,6 @@ gen/standalone/
|
||||
cfg/config.json
|
||||
data/*.json
|
||||
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
|
||||
@@ -112,6 +108,38 @@ build it first: cd soleprint/common/ui && pnpm build
|
||||
Each publish leaves a `.spr` stamp in the destination recording name, version,
|
||||
type, source and mode, so a copy can say where it came from.
|
||||
|
||||
### Slices: only what a consumer uses
|
||||
|
||||
`publish` hands over the whole component. A **slice** hands over the part one
|
||||
consumer actually uses, as readable source, computed rather than picked:
|
||||
|
||||
```bash
|
||||
make ui # list: every export and part, and what it pulls in
|
||||
make ui scan ../mpr/ui/detection-app # what an app uses; its framework copy against spr
|
||||
./ctrl/ui.sh slice path/to/ui.toml # write the slices a run file lists
|
||||
./ctrl/ui.sh check path/to/ui.toml # the same, writing nothing — exit 1 if stale
|
||||
```
|
||||
|
||||
A run file names the consumer, where the slice goes, and whether it is read from
|
||||
the consumer's own imports (`scan`) or named (`use`). `target = "vue"` takes files
|
||||
from `common/ui/src` — `Panel` alone is one file and `vue`; `TimeSeriesRenderer`
|
||||
brings uplot and nothing else does. `target = "plain"` takes parts from
|
||||
`common/theme/parts` as one `ui.css` and one `ui.js`. See
|
||||
`soleprint/common/slice/ui.example.toml`.
|
||||
|
||||
Two deliveries:
|
||||
|
||||
| `delivery` | for | what changes |
|
||||
| --- | --- | --- |
|
||||
| `dependency` | your own projects | nothing: spr's names stay, and a `.spr` stamp says where the copy came from |
|
||||
| `vendored` | a repository that is not spr's | names go through a mapping profile, headers are stripped, no stamp is written, a scrub fails on any spr word, and `notice` must be stated |
|
||||
|
||||
Every run writes a manifest beside its run file recording the hash of each file
|
||||
it wrote. That is what makes drift visible both ways: when spr moves on, `check`
|
||||
says the slice is stale; when someone edits the copy, the next run names the file
|
||||
and refuses to overwrite it without `--force` — so an improvement made in a
|
||||
consumer can be brought back instead of lost.
|
||||
|
||||
## Deploying
|
||||
|
||||
```bash
|
||||
|
||||
@@ -57,8 +57,7 @@ compose, the same dependency installs as a rig addon of that name:
|
||||
|
||||
```bash
|
||||
cd rig
|
||||
PROFILE=data make cluster up
|
||||
PROFILE=data make addons install
|
||||
PROFILE=data make cluster up # installs the addons too
|
||||
|
||||
kubectl -n data port-forward svc/postgres 5432:5432
|
||||
kubectl -n data port-forward svc/airflow 8080:8080
|
||||
|
||||
485
docs/data/en/ui-reference.md
Normal file
485
docs/data/en/ui-reference.md
Normal file
@@ -0,0 +1,485 @@
|
||||
# common/ui — reference
|
||||
|
||||
> Generated from `common/ui/src` by `make reference` in common/ui (`python3 -m common.slice reference`).
|
||||
> Do not edit this page: the next build replaces it. Prose goes in `soleprint/common/ui/notes/<spot>.md`,
|
||||
> and `make reference-spots` lists every spot.
|
||||
|
||||
## What this is
|
||||
|
||||
A panel framework, not a dashboard: boxes with a title and a status light, splits that
|
||||
resize, a feed that pushes typed events, and renderers for what arrives. It was built for
|
||||
high-frequency operational views — a minimal Grafana — and every piece below exists
|
||||
because an app needed it.
|
||||
|
||||
**The contract is the framework; Vue is one binding of it.** The contract is four things:
|
||||
the token names, the element layer (`base.css`), the class names (`.panel`,
|
||||
`.panel-header`, `.split-pane`, …) and the status words. Those are not whole yet: a feed
|
||||
reports `idle`, `connecting`, `live`, `error`, while the panel's dot shows `idle`,
|
||||
`live`, `processing`, `error` — so `connecting` has no dot, and a consumer has to map it
|
||||
by hand. That is a gap in the contract, listed below. `common/theme/parts` is the second
|
||||
binding: the same contract as plain CSS and JS, for pages that must open from a file with
|
||||
no build. docgen's site is not a binding yet: it bakes its colours from its own style
|
||||
slots, a third token vocabulary.
|
||||
|
||||
## The layers, as use shaped them
|
||||
|
||||
| layer | what | the evidence |
|
||||
| --- | --- | --- |
|
||||
| contract | tokens, element layer, class names, status words | every consumer reads `tokens.css`; four apps pasted their own `base.css` before it shipped here |
|
||||
| chrome | `Panel` (+ maximize), `SplitPane`, `ParameterEditor`, `VideoPlayer` | `Panel` and `SplitPane` are the only exports all five consumers use; nested splits, not `LayoutGrid`, turned out to be the layout |
|
||||
| feed | `DataSource`, `SSEDataSource` | one stream, typed events, a terminal `done` |
|
||||
| renderers | `LogRenderer`, `TableRenderer`, `FrameRenderer`, `TimeSeriesRenderer`, `GraphRenderer` | opt-in; the only two external packages arrive through these |
|
||||
| app state | not here | mpr keeps its own Pinia stores; `pinia` is imported nowhere in this library |
|
||||
| delivery | slices, not this whole folder | see below |
|
||||
|
||||
## How to take it
|
||||
|
||||
Not as this folder. A consumer takes a **slice**: the closure of the exports it uses,
|
||||
as readable source, with a barrel exporting only those and a `tokens.css` cut down to
|
||||
what is read — `make ui scan <app>` computes it, `ctrl/ui.sh slice <run file>` writes it,
|
||||
and `check` reports drift both ways. The "Contents" table is the price list.
|
||||
|
||||
## How this document is made
|
||||
|
||||
Everything outside these notes is generated from `src/` by `make reference` (in
|
||||
`common/ui`), so a prop that is removed disappears from here on the next build. The notes
|
||||
are the only hand-written part, one file per spot in `soleprint/common/ui/notes/`; a note
|
||||
whose spot disappears fails `make check`. `make docs` — anywhere in soleprint — launches
|
||||
this site.
|
||||
|
||||
## What came in
|
||||
|
||||
`common/ui/src` — **24 file(s) seen, 24 read, 0 failed**; 19 exports and 15 types in the barrel.
|
||||
|
||||
## Contents
|
||||
|
||||
**Who uses what**, measured 2026-09-22 with `make ui scan <app>` over the five apps that
|
||||
consume this library. It is the one table here that is not generated — the apps live
|
||||
outside this repository — so re-run the scan before trusting a row.
|
||||
|
||||
| export | mpr detection | mts meetus | mts doocus | unt | nvi |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `Panel`, `SplitPane` | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| `LogRenderer` | ✓ | | | ✓ | |
|
||||
| `VideoPlayer` | | ✓ | | | |
|
||||
| `ParameterEditor`, `ResizeHandle`, all renderers but Log, `SSEDataSource`, `useRegistry`, `useEditorExecution` | ✓ | | | | |
|
||||
| `LayoutGrid`, `StaticDataSource`, `useDataSource`, `BBoxDrawPlugin`, `CrosshairPlugin` | | | | | |
|
||||
|
||||
The last row has **no users at all**. Those exports stay in the library — they are
|
||||
small and a slice never carries them unless asked — but nothing should be built on them
|
||||
as if they were proven. `ResizeHandle` is imported by mpr and never rendered: it is what
|
||||
`SplitPane` replaced.
|
||||
|
||||
What each export costs a consumer that takes it: the files it pulls in, the packages it
|
||||
brings, and whether a plain-HTML part exists for it (`common/theme/parts`).
|
||||
|
||||
| export | kind | file | pulls in | packages | size | plain part |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| [`DataSource`](#datasource) | datasource | `datasources/DataSource.ts` | — | vue | 1.3 KB | feed |
|
||||
| [`SSEDataSource`](#ssedatasource) | datasource | `datasources/SSEDataSource.ts` | DataSource.ts | vue | 3.8 KB | feed |
|
||||
| [`StaticDataSource`](#staticdatasource) | datasource | `datasources/StaticDataSource.ts` | DataSource.ts | vue | 2.4 KB | — |
|
||||
| [`useDataSource`](#usedatasource) | composable | `composables/useDataSource.ts` | DataSource.ts | vue | 2.0 KB | — |
|
||||
| [`useRegistry`](#useregistry) | composable | `composables/useRegistry.ts` | — | vue | 1.9 KB | — |
|
||||
| [`useEditorExecution`](#useeditorexecution) | composable | `composables/useEditorExecution.ts` | — | vue | 1.4 KB | — |
|
||||
| [`Panel`](#panel) | component | `components/Panel.vue` | — | vue | 4.3 KB | maximize, panel |
|
||||
| [`LayoutGrid`](#layoutgrid) | component | `components/LayoutGrid.vue` | — | vue | 0.5 KB | — |
|
||||
| [`ResizeHandle`](#resizehandle) | component | `components/ResizeHandle.vue` | — | vue | 1.3 KB | — |
|
||||
| [`SplitPane`](#splitpane) | component | `components/SplitPane.vue` | — | vue | 3.7 KB | split |
|
||||
| [`ParameterEditor`](#parametereditor) | component | `components/ParameterEditor.vue` | — | vue | 3.1 KB | params |
|
||||
| [`VideoPlayer`](#videoplayer) | component | `components/VideoPlayer.vue` | — | vue | 4.1 KB | — |
|
||||
| [`LogRenderer`](#logrenderer) | renderer | `renderers/LogRenderer.vue` | — | vue | 3.7 KB | — |
|
||||
| [`TimeSeriesRenderer`](#timeseriesrenderer) | renderer | `renderers/TimeSeriesRenderer.vue` | — | uplot, vue | 4.3 KB | — |
|
||||
| [`GraphRenderer`](#graphrenderer) | renderer | `renderers/GraphRenderer.vue` | — | @vue-flow/core, vue | 7.9 KB | — |
|
||||
| [`FrameRenderer`](#framerenderer) | renderer | `renderers/FrameRenderer.vue` | — | vue | 4.9 KB | — |
|
||||
| [`TableRenderer`](#tablerenderer) | renderer | `renderers/TableRenderer.vue` | — | vue | 2.4 KB | — |
|
||||
| [`BBoxDrawPlugin`](#bboxdrawplugin) | plugin | `plugins/BBoxDrawPlugin.ts` | InteractionPlugin.ts | — | 3.0 KB | — |
|
||||
| [`CrosshairPlugin`](#crosshairplugin) | plugin | `plugins/CrosshairPlugin.ts` | InteractionPlugin.ts | — | 2.5 KB | — |
|
||||
|
||||
## Components
|
||||
|
||||
### Panel
|
||||
|
||||
The one component everything uses — 13 instances in mpr, 7 across mts's two apps.
|
||||
|
||||
**Maximize came from mts.** mts's copy grew `v-model:maximized` and `maximizable` while
|
||||
spr's stood still, and it was brought upstream on 2026-09-22 — the framework spec's own
|
||||
rule ("a second application finds the holes") working as written. The reason it is the
|
||||
panel's job and not the video's is in the source: a fullscreen `<video>` paints over the
|
||||
panel's actions and overlays.
|
||||
|
||||
**`#actions` is where panel buttons go.** mts fills it in 6 of 7 panels; mpr fills it in
|
||||
none and absolutely-positions buttons inside the body instead. The slot is the answer.
|
||||
**`#overlay` is filled by no consumer**; it stays for the interaction plugins, which have
|
||||
no consumer either.
|
||||
|
||||
`components/Panel.vue` · 4.3 KB with its closure · packages: `vue` ^3.5 · plain part: `maximize`, `panel`
|
||||
|
||||
| prop | type | required | default | |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `title` | `string` | yes | — | — |
|
||||
| `status` | `'idle' \| 'live' \| 'processing' \| 'error'` | no | — | Omit to show no indicator at all — see the note on `.panel-status`. |
|
||||
| `maximizable` | `boolean` | no | `true` | Set false for panels that make no sense filling the window. |
|
||||
|
||||
| v-model | type | default |
|
||||
| --- | --- | --- |
|
||||
| `v-model:maximized` | `boolean` | `false` |
|
||||
|
||||
slots: `#actions`, `#default`, `#overlay`
|
||||
|
||||
reads: `--font-size-sm` `--font-ui` `--panel-border` `--panel-header-height` `--panel-radius` `--space-1` `--space-2` `--space-3` `--status-error` `--status-idle` `--status-live` `--status-processing` `--surface-1` `--surface-2` `--text-primary` `--text-secondary`
|
||||
|
||||
### LayoutGrid
|
||||
|
||||
`components/LayoutGrid.vue` · 0.5 KB with its closure · packages: `vue` ^3.5
|
||||
|
||||
| prop | type | required | default | |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `columns` | `number` | no | `2` | — |
|
||||
| `rows` | `number` | no | `2` | — |
|
||||
| `gap` | `string` | no | `'var(--space-2)'` | — |
|
||||
|
||||
slots: `#default`
|
||||
|
||||
### ResizeHandle
|
||||
|
||||
`components/ResizeHandle.vue` · 1.3 KB with its closure · packages: `vue` ^3.5
|
||||
|
||||
| prop | type | required | default | |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `direction` | `'horizontal' \| 'vertical'` | yes | — | — |
|
||||
|
||||
| emits | payload | |
|
||||
| --- | --- | --- |
|
||||
| `resize` | `[delta: number]` | — |
|
||||
|
||||
reads: `--text-dim`
|
||||
|
||||
### SplitPane
|
||||
|
||||
The real layout primitive. Every app lays itself out with nested splits — mpr three
|
||||
deep — and none uses `LayoutGrid`. `sizeMode="ratio"` is the common case (all of mts);
|
||||
`"px"` pins a sidebar (mpr). `resizable: false` has never been passed by anyone: a split
|
||||
that cannot move is a flex row.
|
||||
|
||||
`components/SplitPane.vue` · 3.7 KB with its closure · packages: `vue` ^3.5 · plain part: `split`
|
||||
|
||||
| prop | type | required | default | |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `direction` | `'horizontal' \| 'vertical'` | no | `'horizontal'` | Split direction |
|
||||
| `initialSize` | `number` | no | `1` | Initial size of the sized pane (px or flex ratio) |
|
||||
| `sizeMode` | `'px' \| 'ratio'` | no | `'ratio'` | Size mode: 'px' = sized pane fixed in pixels, 'ratio' = flex ratio |
|
||||
| `anchor` | `'first' \| 'second'` | no | `'first'` | Which pane is sized: 'first' or 'second'. Default: 'first'. |
|
||||
| `min` | `number` | no | `0.1` | Min size (px in px-mode, ratio in ratio-mode) |
|
||||
| `max` | `number` | no | `10` | Max size (px in px-mode, ratio in ratio-mode) |
|
||||
| `resizable` | `boolean` | no | `true` | Whether the divider is draggable |
|
||||
|
||||
slots: `#first`, `#second`
|
||||
|
||||
reads: `--text-dim`
|
||||
|
||||
### ParameterEditor
|
||||
|
||||
Schema-driven: the same field list a pipeline stage declares renders its sliders. Two
|
||||
gaps, both fixed in the plain binding (`params` part) and not yet here: a field with
|
||||
`options` (an enum) renders **nothing**, because only `int`/`float` and `bool` are
|
||||
filtered for; and the `reset` event is declared but never emitted — mpr wires a handler
|
||||
to it that cannot fire.
|
||||
|
||||
`components/ParameterEditor.vue` · 3.1 KB with its closure · packages: `vue` ^3.5 · plain part: `params`
|
||||
|
||||
| prop | type | required | default | |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `fields` | `ConfigField[]` | yes | — | — |
|
||||
| `values` | `Record<string, unknown>` | yes | — | — |
|
||||
|
||||
| emits | payload | |
|
||||
| --- | --- | --- |
|
||||
| `update` | `[name: string, value: unknown]` | — |
|
||||
| `reset` | `[]` | — |
|
||||
|
||||
reads: `--space-2` `--surface-3` `--text-dim` `--text-primary` `--text-secondary`
|
||||
|
||||
### VideoPlayer
|
||||
|
||||
Agnostic seekable media player.
|
||||
|
||||
mts's, and only mts uses it. It came upstream with its `#overlay` slot on 2026-09-22.
|
||||
It deliberately does no fullscreen of its own: put it in a `Panel` and maximize that.
|
||||
|
||||
`components/VideoPlayer.vue` · 4.1 KB with its closure · packages: `vue` ^3.5
|
||||
|
||||
| prop | type | required | default | |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `src` | `string` | yes | — | Media source URL |
|
||||
| `currentTime` | `number` | no | `0` | Playback position in seconds (v-model:currentTime) |
|
||||
| `playing` | `boolean` | no | `false` | Whether the media is playing (v-model:playing) |
|
||||
|
||||
| emits | payload | |
|
||||
| --- | --- | --- |
|
||||
| `update:currentTime` | `[value: number]` | — |
|
||||
| `update:playing` | `[value: boolean]` | — |
|
||||
| `duration` | `[value: number]` | Media duration once metadata has loaded |
|
||||
| `ended` | `[]` | Playback reached the end |
|
||||
|
||||
slots: `#overlay`
|
||||
|
||||
reads: `--surface-0`
|
||||
|
||||
## Renderers
|
||||
|
||||
### LogRenderer
|
||||
|
||||
Virtual scroll, and the only one in any of the apps: rows outside the viewport are not
|
||||
rendered, and auto-scroll pauses while the reader scrolls up. mts's long lists and mpr's
|
||||
React side render every row instead. `rowHeight` and `autoScroll` have never been passed
|
||||
by a consumer; the defaults are what is proven.
|
||||
|
||||
`renderers/LogRenderer.vue` · 3.7 KB with its closure · packages: `vue` ^3.5
|
||||
|
||||
| prop | type | required | default | |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `entries` | `LogEntry[]` | yes | — | — |
|
||||
| `rowHeight` | `number` | no | `24` | — |
|
||||
| `autoScroll` | `boolean` | no | `true` | — |
|
||||
|
||||
reads: `--font-mono` `--space-2` `--space-6` `--status-error` `--status-escalating` `--status-live` `--status-processing` `--text-dim`
|
||||
|
||||
### TimeSeriesRenderer
|
||||
|
||||
One of the only two exports that bring a package (`uplot`, with its stylesheet), and the
|
||||
reason a chart cannot be a plain part: baking uplot into every standalone page is the
|
||||
bundle this library refuses to be. A page that needs a real chart is a served page.
|
||||
|
||||
`renderers/TimeSeriesRenderer.vue` · 4.3 KB with its closure · packages: `uplot` ^1.6, `vue` ^3.5
|
||||
|
||||
| prop | type | required | default | |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `series` | `TimeSeriesSeries[]` | yes | — | Array of series configs (label + color) |
|
||||
| `data` | `uPlot.AlignedData` | yes | — | Data: [timestamps[], series1[], series2[], ...] |
|
||||
| `title` | `string` | no | — | Chart title (optional) |
|
||||
| `stacked` | `boolean` | no | `false` | Stacked area mode |
|
||||
|
||||
reads: `--font-mono` `--surface-2` `--surface-3` `--text-primary` `--text-secondary`
|
||||
|
||||
### GraphRenderer
|
||||
|
||||
`renderers/GraphRenderer.vue` · 7.9 KB with its closure · packages: `@vue-flow/core` ^1.48.2, `vue` ^3.5
|
||||
|
||||
| prop | type | required | default | |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `nodes` | `GraphNode[]` | yes | — | — |
|
||||
| `mode` | `GraphMode` | no | `'observe'` | Interaction mode — changes visual treatment and click behavior |
|
||||
| `activeStage` | `string \| null` | no | `null` | Currently edited stage (highlighted in edit modes) |
|
||||
| `regionStages` | `string[]` | no | — | Stages that have a region editor (bbox/polygon) |
|
||||
|
||||
| emits | payload | |
|
||||
| --- | --- | --- |
|
||||
| `open-region-editor` | `[stage: string]` | — |
|
||||
| `open-stage-editor` | `[stage: string]` | — |
|
||||
| `node-click` | `[stage: string]` | — |
|
||||
|
||||
reads: `--font-mono` `--font-size-sm` `--panel-radius` `--status-processing` `--text-secondary`
|
||||
|
||||
### FrameRenderer
|
||||
|
||||
**Known bug:** box colours by confidence are returned as `'var(--conf-high)'` strings and
|
||||
handed to a canvas `strokeStyle`. Canvas does not resolve custom properties, so those
|
||||
boxes draw in the context's previous colour. That is why the "Tokens" table shows the
|
||||
`--conf-*` tokens as read by nobody: in effect, they are not.
|
||||
|
||||
`renderers/FrameRenderer.vue` · 4.9 KB with its closure · packages: `vue` ^3.5
|
||||
|
||||
| prop | type | required | default | |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `imageSrc` | `string` | yes | — | Base64 JPEG image |
|
||||
| `boxes` | `FrameBBox[]` | yes | — | Bounding boxes to overlay |
|
||||
| `overlays` | `FrameOverlay[]` | no | — | Debug overlay layers (edge images, line visualizations, etc.) |
|
||||
|
||||
reads: `--text-dim`
|
||||
|
||||
### TableRenderer
|
||||
|
||||
`renderers/TableRenderer.vue` · 2.4 KB with its closure · packages: `vue` ^3.5
|
||||
|
||||
| prop | type | required | default | |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `columns` | `TableColumn[]` | yes | — | — |
|
||||
| `rows` | `Record<string, unknown>[]` | yes | — | — |
|
||||
| `sortKey` | `string` | no | — | — |
|
||||
| `sortDir` | `'asc' \| 'desc'` | no | — | — |
|
||||
|
||||
| emits | payload | |
|
||||
| --- | --- | --- |
|
||||
| `sort` | `[key: string]` | — |
|
||||
|
||||
reads: `--font-mono` `--font-size-sm` `--panel-border` `--space-1` `--space-2` `--space-3` `--space-6` `--surface-2` `--surface-3` `--text-dim` `--text-primary` `--text-secondary`
|
||||
|
||||
## Datasources
|
||||
|
||||
### DataSource
|
||||
|
||||
`datasources/DataSource.ts` · 1.3 KB with its closure · packages: `vue` ^3.5 · plain part: `feed`
|
||||
|
||||
Base class for all data sources. A DataSource connects to some event stream, exposes reactive state, and lets consumers subscribe to typed events. Panels read from these reactively — they never touch the transport layer directly.
|
||||
|
||||
### SSEDataSource
|
||||
|
||||
One stream, many typed events: panels subscribe with `on(type, handler)` to the event
|
||||
types they draw, and the stream ends with a terminal `done`. `status` uses the same four
|
||||
words as a panel's dot, so wiring one to the other needs no mapping.
|
||||
|
||||
Two things the code does not do, both learned from mpr. **It does not throttle**: at a few
|
||||
hundred events a second, a handler that writes to the DOM per event stalls — the plain
|
||||
binding's `feed` part adds `onFrame()` (one call per animation frame, newest payload) for
|
||||
exactly this, and a Vue consumer has to coalesce the same way. **Nobody unsubscribes**:
|
||||
`on()` returns an unsubscribe function and every call site in mpr drops it, which only
|
||||
works because its panels live as long as the page.
|
||||
|
||||
`datasources/SSEDataSource.ts` · 3.8 KB with its closure · packages: `vue` ^3.5 · plain part: `feed`
|
||||
|
||||
DataSource backed by native EventSource (Server-Sent Events). Connects to a single SSE endpoint and demultiplexes events by type. Multiple panels can subscribe to different event types from the same source.
|
||||
|
||||
### StaticDataSource
|
||||
|
||||
`datasources/StaticDataSource.ts` · 2.4 KB with its closure · packages: `vue` ^3.5
|
||||
|
||||
DataSource that replays a fixture array of events. Used for development and testing without a running backend. Events are emitted in sequence with optional delays.
|
||||
|
||||
## Composables
|
||||
|
||||
### useDataSource
|
||||
|
||||
Composable that connects a component to a DataSource.
|
||||
|
||||
`composables/useDataSource.ts` · 2.0 KB with its closure · packages: `vue` ^3.5
|
||||
|
||||
Composable that connects a component to a DataSource. Connects on mount, disconnects on unmount. Returns reactive refs for data, status, and error.
|
||||
|
||||
### useRegistry
|
||||
|
||||
Generic registry composable — fetches typed data from a URL, caches it, exposes it reactively.
|
||||
|
||||
`composables/useRegistry.ts` · 1.9 KB with its closure · packages: `vue` ^3.5
|
||||
|
||||
### useEditorExecution
|
||||
|
||||
`composables/useEditorExecution.ts` · 1.4 KB with its closure · packages: `vue` ^3.5
|
||||
|
||||
Generic editor execution pattern — debounced apply with auto-apply toggle, loading/error/timing state tracking. The caller provides the actual execution function. This composable handles the orchestration: debounce, auto-apply, loading state, timing.
|
||||
|
||||
## Plugins
|
||||
|
||||
### BBoxDrawPlugin
|
||||
|
||||
BBoxDrawPlugin — draw bounding boxes on the frame viewer.
|
||||
|
||||
`plugins/BBoxDrawPlugin.ts` · 3.0 KB with its closure · packages: none
|
||||
|
||||
### CrosshairPlugin
|
||||
|
||||
CrosshairPlugin — synchronized vertical crosshair across time-series panels.
|
||||
|
||||
`plugins/CrosshairPlugin.ts` · 2.5 KB with its closure · packages: none
|
||||
|
||||
## Types
|
||||
|
||||
Types ride with the file that declares them: taking the export takes its types.
|
||||
|
||||
| type | declared in | |
|
||||
| --- | --- | --- |
|
||||
| `DataSourceStatus` | `datasources/DataSource.ts` | type |
|
||||
| `EditorExecutionOptions` | `composables/useEditorExecution.ts` | interface |
|
||||
| `ConfigField` | `components/ParameterEditor.vue` | interface |
|
||||
| `FrameBBox` | `renderers/FrameRenderer.vue` | interface |
|
||||
| `FrameOverlay` | `renderers/FrameRenderer.vue` | interface |
|
||||
| `LogEntry` | `renderers/LogRenderer.vue` | interface |
|
||||
| `GraphNode` | `renderers/GraphRenderer.vue` | interface |
|
||||
| `GraphMode` | `renderers/GraphRenderer.vue` | type |
|
||||
| `TableColumn` | `renderers/TableRenderer.vue` | interface |
|
||||
| `TimeSeriesSeries` | `renderers/TimeSeriesRenderer.vue` | interface |
|
||||
| `InteractionPlugin` | `plugins/InteractionPlugin.ts` | interface |
|
||||
| `PluginContext` | `plugins/InteractionPlugin.ts` | interface |
|
||||
| `BBoxResult` | `plugins/BBoxDrawPlugin.ts` | interface |
|
||||
| `BBoxCallback` | `plugins/BBoxDrawPlugin.ts` | type |
|
||||
| `CrosshairCallback` | `plugins/CrosshairPlugin.ts` | type |
|
||||
|
||||
## Tokens
|
||||
|
||||
**There are two definitions, and the ⚠ rows are where they disagree.** `src/tokens.css`
|
||||
is the library's own, dark-only; the theme contract in `common/theme` derives the same
|
||||
names from its own palette. They agree on every colour but `--surface-3` and on neither
|
||||
font stack. A Vue slice uses the library's values by default so swapping a consumer's
|
||||
copy for a slice changes no pixel; `theme = "soleprint"` opts into the contract, and
|
||||
that is a visible change. Retiring the library's file in favour of the contract is the
|
||||
open question — it changes what every current consumer renders.
|
||||
|
||||
"read by nobody" means no stylesheet in the library reads it. Apps read tokens directly
|
||||
too — `make ui scan` reports between 14 and 23 per app — and a slice carries those.
|
||||
|
||||
Every custom property the library defines or reads. `library` is `src/tokens.css`, what
|
||||
Vue consumers render today; `theme` is the contract plain pages use
|
||||
(`common/theme/tokens.css` + `themes/soleprint.css`). A row where the two differ is a
|
||||
visible change for a consumer that switches.
|
||||
|
||||
| token | library | theme | read by |
|
||||
| --- | --- | --- | --- |
|
||||
| `--border` | `#2e2e38` | `#2e2e38` | tokens.css |
|
||||
| `--conf-high` | `#3ecf8e` | `#3ecf8e` | *nobody* |
|
||||
| `--conf-low` | `#f06565` | `#f06565` | *nobody* |
|
||||
| `--conf-mid` | `#f5a623` | `#f5a623` | *nobody* |
|
||||
| `--font-mono` ⚠ | `'JetBrains Mono', 'Fira Code', monospace` | `"JetBrains Mono", "Cascadia Mono", Consolas, monospace` | GraphRenderer.vue, LogRenderer.vue, TableRenderer.vue, TimeSeriesRenderer.vue |
|
||||
| `--font-size-base` | `13px` | `13px` | base.css |
|
||||
| `--font-size-lg` | `15px` | `15px` | *nobody* |
|
||||
| `--font-size-sm` | `11px` | `11px` | GraphRenderer.vue, Panel.vue, TableRenderer.vue |
|
||||
| `--font-ui` ⚠ | `'Inter', system-ui, sans-serif` | `Inter, "Segoe UI", system-ui, -apple-system, Arial, sans-serif` | Panel.vue, base.css |
|
||||
| `--panel-border` | `1px solid #2e2e38` | `1px solid #2e2e38` | Panel.vue, TableRenderer.vue, base.css |
|
||||
| `--panel-header-height` | `36px` | `36px` | Panel.vue |
|
||||
| `--panel-radius` | `6px` | `6px` | GraphRenderer.vue, Panel.vue, base.css |
|
||||
| `--space-1` | `4px` | `4px` | Panel.vue, TableRenderer.vue, base.css |
|
||||
| `--space-2` | `8px` | `8px` | LogRenderer.vue, Panel.vue, ParameterEditor.vue, TableRenderer.vue |
|
||||
| `--space-3` | `12px` | `12px` | Panel.vue, TableRenderer.vue, base.css |
|
||||
| `--space-4` | `16px` | `16px` | *nobody* |
|
||||
| `--space-6` | `24px` | `24px` | LogRenderer.vue, TableRenderer.vue |
|
||||
| `--space-8` | `32px` | `32px` | *nobody* |
|
||||
| `--status-error` | `#f06565` | `#f06565` | LogRenderer.vue, Panel.vue |
|
||||
| `--status-escalating` | `#f5a623` | `#f5a623` | LogRenderer.vue, tokens.css |
|
||||
| `--status-idle` | `#555568` | `#555568` | Panel.vue |
|
||||
| `--status-live` | `#3ecf8e` | `#3ecf8e` | LogRenderer.vue, Panel.vue, tokens.css |
|
||||
| `--status-processing` | `#4f9cf9` | `#4f9cf9` | GraphRenderer.vue, LogRenderer.vue, Panel.vue, tokens.css |
|
||||
| `--surface-0` | `#0d0d0f` | `#0d0d0f` | VideoPlayer.vue, base.css, theme.css |
|
||||
| `--surface-1` | `#16161a` | `#16161a` | Panel.vue |
|
||||
| `--surface-2` | `#1e1e24` | `#1e1e24` | Panel.vue, TableRenderer.vue, TimeSeriesRenderer.vue, base.css |
|
||||
| `--surface-3` ⚠ | `#26262f` | `#2e2e38` | ParameterEditor.vue, TableRenderer.vue, TimeSeriesRenderer.vue, base.css |
|
||||
| `--text-dim` | `#555568` | `#555568` | FrameRenderer.vue, LogRenderer.vue, ParameterEditor.vue, ResizeHandle.vue, SplitPane.vue, TableRenderer.vue |
|
||||
| `--text-primary` | `#e8e8f0` | `#e8e8f0` | Panel.vue, ParameterEditor.vue, TableRenderer.vue, TimeSeriesRenderer.vue, base.css |
|
||||
| `--text-secondary` | `#8888a0` | `#8888a0` | GraphRenderer.vue, Panel.vue, ParameterEditor.vue, TableRenderer.vue, TimeSeriesRenderer.vue |
|
||||
|
||||
## What came out
|
||||
|
||||
nodes: 1 barrel · 6 component · 3 composable · 3 datasource · 19 export · 3 package · 6 part · 3 plugin · 5 renderer · 3 stylesheet · 30 token · 15 type
|
||||
|
||||
edges: 7 binds · 28 imports · 70 reads · 17 requires
|
||||
|
||||
- ok 24 file(s) read produced 24 file node(s)
|
||||
- ok 19 export(s) and 15 type(s) in the barrel, each with a node
|
||||
- ok 11 of 11 component(s) have their surface read
|
||||
- ok every note lands on a spot
|
||||
|
||||
## Where it stands
|
||||
|
||||
- **Done:** the reference is generated and checked; slices are cut per consumer with a
|
||||
two-way drift check; mts's additions (maximize, the video overlay) are upstream.
|
||||
- **Behind:** the copies in mpr, unt and nvi date from 2026-04 and lack maximize,
|
||||
`VideoPlayer`, `base.css` and `theme.css`. Their `.spr` stamp names a commit that no
|
||||
longer exists. Replacing each with a slice is the next step for each.
|
||||
- **Open:** one token definition instead of two (three, counting docgen's style slots);
|
||||
the `connecting` status the panel cannot show; `--accent`, which nvi and doocus read and
|
||||
nothing defines; the `FrameRenderer` colour bug; `ParameterEditor`'s enum fields.
|
||||
|
||||
## Rebuilding this
|
||||
|
||||
`make reference` in `common/ui`. `make check` fails when this page is stale, when a
|
||||
generated file was edited by hand, or when a note no longer has its spot. Extraction reads the
|
||||
source with patterns, not a TypeScript parser; `common/slice/selftest.py` pins what the
|
||||
patterns must find, so one that stops matching fails the suite rather than thinning this
|
||||
page.
|
||||
@@ -167,6 +167,13 @@
|
||||
"en": "Shared Components"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ui-reference",
|
||||
"title": {
|
||||
"en": "↳ UI reference"
|
||||
},
|
||||
"sub": true
|
||||
},
|
||||
{
|
||||
"id": "export",
|
||||
"title": {
|
||||
|
||||
2701
docs/data/ui-inventory.json
Normal file
2701
docs/data/ui-inventory.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,14 @@
|
||||
{
|
||||
"soleprint-ui": {
|
||||
"type": "npm",
|
||||
"path": "soleprint/common/ui"
|
||||
"soleprint-cli": {
|
||||
"type": "python",
|
||||
"path": "soleprint/station/tools/common"
|
||||
},
|
||||
"soleprint-modelgen": {
|
||||
"type": "pip",
|
||||
"path": "soleprint/station/tools/modelgen"
|
||||
},
|
||||
"soleprint-ui": {
|
||||
"type": "npm",
|
||||
"path": "soleprint/common/ui"
|
||||
}
|
||||
}
|
||||
|
||||
22
rig/.gitignore
vendored
22
rig/.gitignore
vendored
@@ -6,15 +6,17 @@ def
|
||||
.env.local
|
||||
ctrl/.env
|
||||
|
||||
# generated: the .dot is a build artifact rendered from arch/*.json, never hand-edited.
|
||||
# The .svg IS committed — onboarding material should render in a repo browser.
|
||||
arch/*.dot
|
||||
ctrl/Tiltfile.gen
|
||||
|
||||
# binaries pulled by `make deps-bundle` for the air-gapped wizard image
|
||||
# the manifests the air-gapped installer image unpacks (docs/notes/Dockerfile.deps.md)
|
||||
vendor
|
||||
|
||||
# Client rigs are NOT ignored here. A copy is a SIBLING of this directory
|
||||
# (spr/acme-rig), so a rule in this file cannot see it — the rules live in
|
||||
# spr/.gitignore, anchored at spr's root, where `*-rig/` matches the siblings and
|
||||
# `!rig/sample-rig/` keeps the committed stand-in.
|
||||
# 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/
|
||||
|
||||
1
rig/.version
Normal file
1
rig/.version
Normal file
@@ -0,0 +1 @@
|
||||
0.1.3
|
||||
158
rig/BOOTSTRAP.md
158
rig/BOOTSTRAP.md
@@ -1,19 +1,9 @@
|
||||
# From a machine with nothing on it to a project you can work in
|
||||
# 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 a new project running under
|
||||
actually looks like end to end: a bare Linux box, and an overlay running under
|
||||
Tilt at the end of it.
|
||||
|
||||
rig lives inside soleprint, at `spr/rig` — it is soleprint's cluster half, and
|
||||
a client copy is a sibling (`spr/acme-rig`). Paths below are relative to
|
||||
soleprint's checkout.
|
||||
|
||||
It spans three repos because the work does. **rig** prepares the machine — the
|
||||
pinned toolchain, the cluster, the port arithmetic. **all** owns the shape a
|
||||
project takes, in `all/projects/templates/conventions.md` and the `broad`
|
||||
scaffold beside it. **ppl** owns everything after local, and is where this
|
||||
document stops.
|
||||
|
||||
Read it once before running anything. Three of the steps below need root and one
|
||||
needs a logout, so knowing about them in advance is cheaper than meeting them
|
||||
halfway through.
|
||||
@@ -50,7 +40,7 @@ isn't.
|
||||
## Read the docs before installing anything
|
||||
|
||||
```bash
|
||||
cd spr/rig
|
||||
cd rig
|
||||
make docs
|
||||
```
|
||||
|
||||
@@ -67,11 +57,11 @@ persists; ctrl-c ends it.
|
||||
## Ask what is wrong with this machine
|
||||
|
||||
```bash
|
||||
make station
|
||||
make check
|
||||
cp ctrl/.env.example ctrl/.env
|
||||
```
|
||||
|
||||
`station.sh` reports and instructs, and fixes nothing. It runs bare rather than
|
||||
`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.
|
||||
|
||||
@@ -80,7 +70,7 @@ port rig binds derives from this directory's name, so the answer is specific to
|
||||
this copy, and a clash here surfaces as an opaque `failed to bind host port` in
|
||||
the middle of cluster creation if you skip it.
|
||||
|
||||
Copy the `.env` even though station only warns about it. It is gitignored, it is
|
||||
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.
|
||||
|
||||
|
||||
@@ -88,33 +78,32 @@ where a machine-local override goes, and `ports.sh persist` expects it to exist.
|
||||
|
||||
This is the step where "nothing installed" stops being rhetorical.
|
||||
|
||||
`make deps` runs `ctrl/wizard.sh install` directly on the host, and the wizard
|
||||
`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.wizard` exists
|
||||
to kill — the wizard carries its own toolchain so the host needs only Docker —
|
||||
but building the image and running it are two different things, and only the
|
||||
build has a Makefile target today. **On a genuinely bare machine, run it by
|
||||
hand:**
|
||||
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 `make deps image` only builds the image; running it is the step below.
|
||||
**On a genuinely bare machine, run it by hand:**
|
||||
|
||||
```bash
|
||||
make wizard # builds rig-wizard:wizard
|
||||
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-wizard:wizard install dev
|
||||
rig-deps:deps install dev
|
||||
```
|
||||
|
||||
The image name follows the directory, like everything else here: in `spr/rig`
|
||||
it is `rig-wizard`, in a copy called `spr/acme-rig` it is `acme-rig-wizard`. The
|
||||
tag is `wizard` (or `full`, below), not `latest`.
|
||||
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 wizard reads the *host's* `/etc/os-release` and
|
||||
- **`/:/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.
|
||||
@@ -122,7 +111,7 @@ None of the four arguments are guessable, so:
|
||||
and how it counts kind clusters already running.
|
||||
- **`/out/bin`** — the image's `OUT_BIN`. Whatever you mount here is where the
|
||||
four binaries land.
|
||||
- **`HOST_UID` / `HOST_GID`** — the wizard runs as root so it can reach that
|
||||
- **`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.
|
||||
@@ -131,18 +120,18 @@ None of the four arguments are guessable, so:
|
||||
tooling — which is the right answer on a managed or corporate-issued machine and
|
||||
is why the split exists.
|
||||
|
||||
Then put them on PATH, which the wizard will remind you about because it cannot
|
||||
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 wizard says so
|
||||
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 wizard full` bakes
|
||||
**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`
|
||||
@@ -153,22 +142,24 @@ 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 setup
|
||||
make check
|
||||
make cluster up
|
||||
kubectl get nodes
|
||||
```
|
||||
|
||||
`make setup` re-runs every check as a group. It is idempotent and it deliberately
|
||||
does not abort on the first failure — a setup script that dies at step two hides
|
||||
the fact that steps four and five were also going to fail. Run now, it should be
|
||||
`ok` and `done` all the way down, and that is the point: it is the scoreboard,
|
||||
not the installer.
|
||||
`make 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 the default `minimal` profile — one node, no addons,
|
||||
`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`
|
||||
@@ -186,93 +177,42 @@ worth reading before rather than after. `make cluster free <names>` stops
|
||||
clusters without deleting them; `docker start` brings them back untouched.
|
||||
|
||||
|
||||
## Scaffold the project
|
||||
## Start an overlay
|
||||
|
||||
The canonical layout is [`all/projects/templates/conventions.md`](../all/projects/templates/conventions.md).
|
||||
Read it — it is short, opinionated, and exists precisely so nobody
|
||||
reverse-engineers a layout from whichever repo they happened to open. What
|
||||
follows is only the mechanical part.
|
||||
What runs lives outside rig, in an overlay — see
|
||||
[`docs/notes/overlay.md`](docs/notes/overlay.md). Start from rig's own:
|
||||
|
||||
```bash
|
||||
SLUG=<slug> # short, lowercase, no separators
|
||||
cp -r ~/wdir/all/projects/templates/broad ~/wdir/"$SLUG"
|
||||
cd ~/wdir/"$SLUG"
|
||||
grep -rl '<slug>' ctrl | xargs sed -i "s/<slug>/$SLUG/g"
|
||||
cp ctrl/k8s/.env.example ctrl/k8s/.env
|
||||
git init && git add -A && git commit -m "scaffold $SLUG from broad"
|
||||
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
|
||||
```
|
||||
|
||||
`<slug>` is the only placeholder and it lives only under `ctrl/` — cluster name,
|
||||
namespace, ConfigMap name, and the `NAME=` in `kind-up.sh` / `kind-down.sh`. One
|
||||
sed does all of it.
|
||||
|
||||
The slug is the folder name, lowercase and short — `mpr`, `unt`, `nvi`. The
|
||||
cluster takes that name and the context becomes `kind-<slug>`, derived by the
|
||||
scaffold's Makefile from the directory, so there is nothing to edit for either.
|
||||
|
||||
**Pick the Tilt port deliberately.** `ctrl/k8s/.env.example` ships a value that
|
||||
is already in use, so copying it unchanged puts two projects on one port:
|
||||
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
|
||||
grep -h '^TILT_PORT=' ~/wdir/*/ctrl/k8s/.env 2>/dev/null | sort
|
||||
kubectl kustomize local/myenv/k8s/overlays/dev
|
||||
```
|
||||
|
||||
Choose a free one in `10300–10399` — the range ALL reserves in
|
||||
`projects/index.json` under `policy` — avoiding `10350`, which is Tilt's own
|
||||
default. Currently taken: `nvi` 10330, `unt` 10340, `mpr` 10360, `mlv` 10370,
|
||||
`eth` 10380, `lng` 10390. This is the Tilt *web UI* port, not a service port;
|
||||
each project owns its own service ports separately. The scaffold ships it blank
|
||||
on purpose, so there is nothing to collide with until you choose.
|
||||
|
||||
The scaffold's `ctrl/k8s/` is the same shape as every other project here, and it
|
||||
builds as shipped:
|
||||
|
||||
```
|
||||
kind-config.yaml one node; gateway NodePort 30080 -> hostPort 8080
|
||||
base/ namespace, configmap, app (Deployment + Service)
|
||||
overlays/dev/ promotes the app Service to NodePort 30080
|
||||
```
|
||||
|
||||
Check it before `kind` spends minutes on anything — this renders the whole tree
|
||||
without a cluster and catches a broken patch immediately:
|
||||
|
||||
```bash
|
||||
kubectl kustomize ctrl/k8s/overlays/dev
|
||||
```
|
||||
|
||||
The workload is an nginx placeholder so a fresh copy reaches something that
|
||||
answers; replace it. Keep `30080` in step between the overlay patch and
|
||||
`kind-config.yaml`'s `containerPort` — the hostPort is this project's to pick.
|
||||
Reachability is a plain kind port mapping: no ingress controller and no MetalLB.
|
||||
Caddy maps `<slug>.local.ar` onto the host port (`~/wdir/ppl/local/Caddyfile`),
|
||||
with `*.local.ar` resolving to 127.0.0.1 through dnsmasq. That is the whole chain.
|
||||
|
||||
**The one file the scaffold still does not ship is `ctrl/Tiltfile`** — `make
|
||||
tilt-up` runs `cd ctrl && tilt up`, and there is nothing to run until you write
|
||||
one. Copy it from a live project; `unt` and `nvi` are closest to the plain shape.
|
||||
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
|
||||
make cluster up # idempotent create, then selects the context
|
||||
make tilt # context + your assigned port
|
||||
```
|
||||
|
||||
`tilt-up` passes `--context kind-<slug>` every time, which is the point of going
|
||||
`make tilt` 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
|
||||
`make tilt down` and `make cluster down` close the loop, and `make cluster reset` is
|
||||
delete-and-recreate for when a cluster wedges.
|
||||
|
||||
|
||||
## Register it
|
||||
|
||||
The project exists; now it is findable. Add an entry to
|
||||
`~/wdir/all/projects/index.json` and write its `projects/<slug>.md` beside the
|
||||
others. Structured fields in the index, prose in the markdown.
|
||||
|
||||
Putting it on the CI server and deploying it is `ppl`'s half, and it starts at
|
||||
`~/wdir/ppl/ctrl/init-repo.sh` — gitea remote, then Woodpecker. That is a
|
||||
different document.
|
||||
|
||||
142
rig/Makefile
142
rig/Makefile
@@ -1,109 +1,87 @@
|
||||
# Thin control Makefile — one target per ctrl/ script, and the subcommand is an
|
||||
# argument rather than a second target: `make cluster down`, not `make cluster-down`.
|
||||
#
|
||||
# The logic lives in the scripts, never here. Each target maps to exactly one
|
||||
# bash file, and that file holds the variants:
|
||||
#
|
||||
# make cluster up -> ctrl/cluster.sh up
|
||||
# make newbox destroy -> ctrl/newbox.sh destroy
|
||||
#
|
||||
# Config layers, weakest first: ctrl/versions.env (pinned toolchain) <
|
||||
# ctrl/env.d/<profile>.env (cluster shape) < ctrl/.env (local, gitignored) <
|
||||
# the environment. So `make cluster up PROFILE=client` beats everything.
|
||||
#
|
||||
# Start with: make setup (then: make cluster up && make docs)
|
||||
|
||||
# Identity follows the FOLDER NAME, so this directory can be copied elsewhere,
|
||||
# renamed, and run as a separate environment with no edits. ctrl/.env overrides
|
||||
# it when you want a name that differs from the directory.
|
||||
# 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/-*$$//')
|
||||
CLUSTER := $(or $(shell sed -n 's/^CLUSTER=//p' ctrl/.env 2>/dev/null),$(SLUG))
|
||||
KCTX := --context kind-$(CLUSTER)
|
||||
TILT_PORT := $(shell sed -n 's/^TILT_PORT=//p' ctrl/.env 2>/dev/null)
|
||||
WIZARD := $(SLUG)-wizard
|
||||
# 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. `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.
|
||||
# ...and as PHONY, because some of those words name real directories (ctrl, docs, ...).
|
||||
.PHONY: $(ARGS)
|
||||
endif
|
||||
|
||||
.PHONY: help setup station deps wizard cluster registry addons ports \
|
||||
newbox dockerhost docs tilt \
|
||||
.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
|
||||
|
||||
# ── setup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
setup: ## prepare this machine [core] [--share-docker] [--cluster]
|
||||
bash ctrl/setup.sh $(ARGS)
|
||||
|
||||
station: ## is this workstation ready? reports, never fixes
|
||||
bash ctrl/station.sh
|
||||
|
||||
deps: ## install the toolchain [core|dev] (default dev)
|
||||
bash ctrl/wizard.sh install $(or $(ARGS),dev)
|
||||
|
||||
wizard: ## build the installer image [full]
|
||||
docker build -f ctrl/Dockerfile.wizard \
|
||||
--target $(if $(filter full,$(ARGS)),wizard-full,wizard) \
|
||||
-t $(WIZARD):$(if $(filter full,$(ARGS)),full,wizard) .
|
||||
# ── 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
|
||||
|
||||
# ── cluster ────────────────────────────────────────────────────────────────
|
||||
# ── 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)
|
||||
|
||||
registry: ## registry wiring [up|down|status] (default status)
|
||||
bash ctrl/registry.sh $(or $(ARGS),status)
|
||||
|
||||
addons: ## profile addons [install|list] (default list)
|
||||
bash ctrl/addons.sh $(or $(ARGS),list)
|
||||
|
||||
ports: ## this environment's port block [show|persist]
|
||||
bash ctrl/ports.sh $(or $(ARGS),show)
|
||||
|
||||
# ── host ───────────────────────────────────────────────────────────────────
|
||||
|
||||
newbox: ## throwaway environment [create|status|shell|destroy]
|
||||
bash ctrl/newbox.sh $(or $(ARGS),status)
|
||||
|
||||
dockerhost: ## share Docker between distros [status|share|unshare]
|
||||
$(if $(filter share unshare,$(ARGS)),sudo ,)bash ctrl/dockerhost.sh $(or $(ARGS),status)
|
||||
|
||||
# ── docs + dev loop ────────────────────────────────────────────────────────
|
||||
# ── dev loop + docs ────────────────────────────────────────────────────────
|
||||
|
||||
docs: ## documentation [serve|graphs] (default serve)
|
||||
bash ctrl/docs.sh $(or $(ARGS),serve)
|
||||
|
||||
# --port is only passed when TILT_PORT is actually set. It comes from ctrl/.env,
|
||||
# which does NOT carry it by default — ports are derived at runtime in
|
||||
# lib/config.sh unless `make ports persist` has written them. Without the guard
|
||||
# tilt receives a bare `--port` with no value and fails on the flag rather than
|
||||
# on anything real. `make ports show` prints the derived block.
|
||||
# --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, not a second implementation: each one calls the same script the
|
||||
# canonical target does.
|
||||
#
|
||||
# The header above argues for `make cluster down` over `make cluster-down`, and
|
||||
# that still holds *within* this file. But rig is one repo among several on the
|
||||
# same machine, and every other one answers to kind-up / tilt-up. Muscle memory
|
||||
# spanning six projects beats internal tidiness in one, so both spellings work.
|
||||
#
|
||||
# `cluster list` and `cluster free` have no hyphenated twin on purpose — they
|
||||
# are rig's own, with nothing to be consistent with.
|
||||
# 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
|
||||
@@ -114,10 +92,10 @@ kind-down: ## alias for `cluster down`
|
||||
kind-reset: ## alias for `cluster reset`
|
||||
bash ctrl/cluster.sh reset
|
||||
|
||||
# These two match the other projects' spelling, but rig has no Tiltfile — there
|
||||
# is nothing to run yet, and they fail the same way `make tilt` does.
|
||||
tilt-up: ## alias for `tilt up` (rig has no Tiltfile yet)
|
||||
# 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` (rig has no Tiltfile yet)
|
||||
tilt-down: ## alias for `tilt down`
|
||||
cd ctrl && tilt down $(KCTX)
|
||||
|
||||
162
rig/README.md
162
rig/README.md
@@ -1,117 +1,99 @@
|
||||
# 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.
|
||||
rig builds a disposable Kubernetes environment on your machine, so you can see how a system
|
||||
fits together without access to any of it. It is for **onboarding and exploration**, not a
|
||||
production replica.
|
||||
|
||||
## Prerequisite
|
||||
|
||||
**Docker.** Nothing else — no curl, no jq, no python, no apt repositories.
|
||||
Most services in it are **not real**. What has to be faithful is the topology: names, ports,
|
||||
dependency order, who can reach whom, and how it fails. That is what makes a whole estate fit on
|
||||
a laptop. A real 20-service platform does not fit once in 14 GB; stubs of about 30 MB each fit
|
||||
three times.
|
||||
|
||||
## Read the docs first
|
||||
Under the hood it is a kind cluster, a local image registry and a Tilt dev loop, each with its
|
||||
own name and ports, so several environments run side by side. What runs on it is an
|
||||
**overlay**: a folder outside rig that holds one use case.
|
||||
|
||||
```bash
|
||||
make docs # serves on localhost, prints the URL
|
||||
```
|
||||
## Using rig from a project
|
||||
|
||||
They run before anything is installed, which matters because they are the
|
||||
instructions for everything else. No cluster and no toolchain required.
|
||||
You don't need to read rig to use it. Get a release (`rig-<version>.tar.gz`, or a checkout at
|
||||
the tag `rig-<version>`), extract it anywhere, and point the project at it (`RIG_DIR`). Then run
|
||||
`make check`: it says what the machine still lacks and which command comes next. `.version`
|
||||
names the release; a project records the one it was last run against. See
|
||||
[`docs/notes/overlay.md`](docs/notes/overlay.md).
|
||||
|
||||
## Then
|
||||
## Prerequisite
|
||||
|
||||
```bash
|
||||
make station # report host and config problems; changes nothing
|
||||
make deps # install the toolchain (add `core` on a managed machine)
|
||||
make cluster up # build the cluster for the active profile
|
||||
```
|
||||
**Docker.** Nothing else: no curl, jq, python or apt repositories.
|
||||
|
||||
`make help` lists every target.
|
||||
On Windows, rig runs inside WSL. Run `wsl --install` once, from an elevated shell, then reboot.
|
||||
Work inside the Linux filesystem, not `/mnt/c`: file watching doesn't work there, and
|
||||
`make deps` warns about it. Git Bash, MSYS and Cygwin don't work (no `/proc`, no docker socket).
|
||||
|
||||
On a machine where Docker really is the only thing installed, `make deps` has
|
||||
nothing to download with — see [BOOTSTRAP.md](BOOTSTRAP.md), which runs the
|
||||
toolchain through the wizard container and carries on to scaffolding and running
|
||||
a new project.
|
||||
| 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 |
|
||||
|
||||
## One directory is one environment
|
||||
On a machine with nothing but Docker, [BOOTSTRAP.md](BOOTSTRAP.md) runs the toolchain through
|
||||
the installer container.
|
||||
|
||||
Copy this directory, rename it, run it. Cluster name, kubectl context, image
|
||||
tags and the host port block all derive from the directory name, so copies never
|
||||
collide and neither one's teardown can touch the other.
|
||||
## What it does
|
||||
|
||||
rig lives inside soleprint, at `spr/rig` — it is soleprint's cluster half, and a
|
||||
copy is a **sibling**: `spr/acme-rig`. That is why the ignore rules for client
|
||||
rigs sit in `spr/.gitignore` rather than here; a rule in this directory cannot
|
||||
see a directory beside it.
|
||||
| command | does |
|
||||
| --- | --- |
|
||||
| `make docs` | serves the docs locally; works before anything is installed |
|
||||
| `make check` | is this machine ready? memory, toolchain, cluster, the next command (`check all` for every detail) |
|
||||
| `make deps` | installs the pinned toolchain (`deps core` on a managed machine) |
|
||||
| `make cluster up` | the cluster, its registry and the configured addons (`down`, `reset`, `list`, `free`) |
|
||||
| `make tilt` | the dev loop: rig's part, then the overlay's Tiltfile |
|
||||
| `make selftest` | checks rig itself |
|
||||
| `make help` | every target |
|
||||
|
||||
## Profiles
|
||||
Every verb is a `Makefile` target calling one script in `ctrl/`. The aliases (`kind-up`,
|
||||
`tilt-up`, …) call the same scripts.
|
||||
|
||||
A profile is the shape of the cluster: how many nodes, which addons, whether the
|
||||
apiserver audits. They live in `ctrl/env.d/`, and the active one is `PROFILE`.
|
||||
## Overlays
|
||||
|
||||
| Profile | For |
|
||||
| --- | --- |
|
||||
| `minimal` | the default. One node, no addons, boots fast. |
|
||||
| `client` | the regulated-estate shape — multi-node, audit on, registry mirror. |
|
||||
| `offline` | air-gapped: everything from a preloaded local registry. |
|
||||
| `data` | the dependency containers a soleprint room asks for. |
|
||||
An overlay holds its settings (`rig.env`), its manifests (`k8s/overlays/dev`), its Tiltfile and
|
||||
images, its addons, and its kind config if it needs one. rig reads it and never writes into it.
|
||||
|
||||
```bash
|
||||
PROFILE=data make cluster up
|
||||
PROFILE=data make addons install
|
||||
make addons # what the active profile wants, and what exists
|
||||
cp -r examples/starter local/myenv # local/ is ignored
|
||||
OVERLAY=local/myenv make cluster up
|
||||
OVERLAY=local/myenv make tilt
|
||||
```
|
||||
|
||||
A profile names a **cluster shape** — a file in `ctrl/k8s/` — rather than
|
||||
restating node count and audit as variables:
|
||||
With no overlay named, rig runs `examples/starter`. [`examples/data`](examples/data/) adds
|
||||
postgres, redis and airflow as plain manifests. A project can carry rig at `./rig`, or keep it
|
||||
anywhere and name it with `RIG_DIR`.
|
||||
|
||||
| shape | nodes | audit | used by |
|
||||
| --- | --- | --- | --- |
|
||||
| `kind-config.yaml.tpl` | 1 | off | `minimal`, `data` |
|
||||
| `kind-config.audit.yaml.tpl` | 1 | on | `offline` |
|
||||
| `kind-config.client.yaml.tpl` | 3 | on | `client` |
|
||||
The cluster name, context, image tags and ports derive from the overlay folder's name (rig's
|
||||
own, with no overlay), so environments never collide, and one's teardown never touches another.
|
||||
|
||||
Both numbers are read back out of the chosen file, so the YAML is the only place
|
||||
that decides and there is nothing to drift. The layout under `ctrl/k8s/` is the
|
||||
same as every other project here — a kind config, a kustomize `base/`, an
|
||||
`overlays/dev/` — see [`ctrl/k8s/README.md`](ctrl/k8s/README.md).
|
||||
## Configuration
|
||||
|
||||
## Addons
|
||||
Layers, weakest first: built-in defaults < `ctrl/versions.env` < the profile < the overlay's
|
||||
`rig.env` < `ctrl/.env` < the command line. A profile (`ctrl/env.d/`) is optional and says how
|
||||
this machine reaches the world: `mirror` (a pull-through cache) and `offline` (air-gapped) ship
|
||||
as examples. The cluster itself is one kind template: the overlay's, or rig's one-node default.
|
||||
|
||||
Each addon is its own idempotent script in `ctrl/addons/`, and a profile names
|
||||
the ones it wants in `ADDONS`. Adding one is adding a file — there is no
|
||||
dispatcher to edit.
|
||||
rig's own addons make the cluster work: `metallb` (LoadBalancer addresses), `cert-manager` (a
|
||||
local CA), `metrics-server` (`kubectl top`). A workload's database, cache or scheduler belongs to
|
||||
its overlay. There is no ingress controller: services are reached through LoadBalancer
|
||||
addresses, or through the overlay's own gateway on the environment's HTTP port.
|
||||
|
||||
**There is no ingress controller, deliberately.** They pin a narrow window of
|
||||
Kubernetes versions, so depending on one would constrain which k8s a rig can be
|
||||
built with — and running a trailing-edge control plane to model a legacy estate
|
||||
is the whole point. Services are reached through MetalLB and
|
||||
`type: LoadBalancer`, which carries no such constraint and is also what a real
|
||||
cluster does.
|
||||
## Where things are
|
||||
|
||||
| Addon | Does |
|
||||
| --- | --- |
|
||||
| `metallb` | gives `type: LoadBalancer` an address it can actually reach |
|
||||
| `cert-manager` | a local CA, so TLS works offline |
|
||||
| `metrics-server` | makes `kubectl top` work on kind |
|
||||
| `postgres` | database, in the `data` namespace |
|
||||
| `redis` | cache and broker |
|
||||
| `airflow` | scheduled pipelines; needs postgres and redis |
|
||||
|
||||
The last three are the cluster half of **soleprint's cabinets**. A room declares
|
||||
what it needs once, in `cfg/<room>/data/cabinets.json`; soleprint's `build.py`
|
||||
composes those services into `docker-compose.yml` for a laptop, and these
|
||||
install the same ones here. The names match on purpose — each cabinet carries a
|
||||
`rig_addon` field pointing at `ctrl/addons/<name>.sh`.
|
||||
|
||||
Plain manifests rather than helm charts, like every other addon: a chart repo is
|
||||
a network dependency, and the `offline` profile exists precisely so there is a
|
||||
path with none. Images are pinned in `ctrl/versions.env` and can be preloaded.
|
||||
|
||||
Passwords are generated on first install and kept across re-runs, so re-running
|
||||
an addon never rotates a credential out from under something already connected:
|
||||
|
||||
```bash
|
||||
kubectl -n data get secret postgres -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d
|
||||
kubectl -n data port-forward svc/airflow 8080:8080
|
||||
```
|
||||
Makefile the verbs
|
||||
.version this release: patch and minor steps are edits here; milestones get a tag
|
||||
ctrl/ one script per verb; lib/config.sh (settings and layers); Tiltfile (rig's
|
||||
half of the dev loop); k8s/kind-config.yaml.tpl; addons/; env.d/; versions.env
|
||||
docs/ index.html (make docs); notes/, one per script: why the code is as it is
|
||||
examples/ starter (the default overlay), data (postgres, redis, airflow)
|
||||
standalone/ single-file kits generated from ctrl/ (make standalone); never edited by hand
|
||||
tests/ the installer's host fixtures
|
||||
BOOTSTRAP.md from a bare machine with only Docker
|
||||
STALE.md assumptions withdrawn, each checked by selftest
|
||||
```
|
||||
|
||||
68
rig/STALE.md
Normal file
68
rig/STALE.md
Normal 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.
|
||||
@@ -1,49 +1,42 @@
|
||||
# Machine-local config. Copy to ctrl/.env (gitignored) and edit.
|
||||
# Cluster SHAPE lives in ctrl/env.d/<profile>.env — not here.
|
||||
# The architecture MODEL lives in arch/<name>.json — not here either.
|
||||
# Cluster SHAPE: an optional profile in ctrl/env.d/.
|
||||
# Notes: docs/notes/env.md
|
||||
|
||||
# Which profile in ctrl/env.d/ to build. minimal | client | offline
|
||||
PROFILE=minimal
|
||||
# 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 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.
|
||||
# LEAVE UNSET: it defaults to this folder's name, which keeps the folder copyable.
|
||||
# CLUSTER=
|
||||
|
||||
# Host ports. LEAVE UNSET — they derive from the directory name so several
|
||||
# environments coexist without negotiating (see ctrl/ports.sh). `make ports`
|
||||
# shows this environment's block; `make ports persist` writes it here so it stops
|
||||
# being derived and becomes fixed. Set a value only to override.
|
||||
# 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 application manifests live. The real ones are expected to be
|
||||
# versioned separately from this installer — they change on a different cadence,
|
||||
# by different people. Repoint this at their repo and rig stops owning them:
|
||||
# MANIFESTS_DIR=../platform-manifests/overlays/dev
|
||||
MANIFESTS_DIR=ctrl/k8s/overlays/dev
|
||||
# Where the manifests live. Leave unset: the overlay's k8s/overlays/dev.
|
||||
# MANIFESTS_DIR=../platform-manifests/overlays/dev
|
||||
|
||||
# Where the wizard fetches the pinned binaries from.
|
||||
# upstream GitHub releases / dl.k8s.io (needs internet)
|
||||
# artifactory a generic repo — what a locked-down client usually allows
|
||||
# baked already inside the wizard image; no network at all
|
||||
# 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). These are the secrets it needs.
|
||||
# Required for mirror/remote:
|
||||
# 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 (it usually is).
|
||||
# Trust has to reach THREE places and nothing does it for you: the host docker
|
||||
# daemon, every kind node's containerd, and any in-cluster client. registry.sh
|
||||
# handles the first two; station.sh reports when it's configured but not trusted.
|
||||
# Corporate root CA, if Artifactory is fronted by an internal CA.
|
||||
# Symptom when missing: x509: certificate signed by unknown authority
|
||||
REGISTRY_CA_FILE=
|
||||
|
||||
|
||||
36
rig/ctrl/Dockerfile.deps
Normal file
36
rig/ctrl/Dockerfile.deps
Normal 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
|
||||
@@ -1,46 +0,0 @@
|
||||
# The installation wizard. It does NOT run the cluster — it installs a toolchain
|
||||
# onto the host and gets out of the way.
|
||||
#
|
||||
# This exists to kill a bootstrap paradox: a plain bash installer needs curl, jq
|
||||
# and sha256sum to already be present, and a minimal Debian has none of them.
|
||||
# The wizard carries its own toolchain, so the only host prerequisite is Docker.
|
||||
#
|
||||
# Two variants from one file:
|
||||
# docker build -f ctrl/Dockerfile.wizard --target wizard -t <slug>-wizard .
|
||||
# docker build -f ctrl/Dockerfile.wizard --target wizard-full -t <slug>-wizard:full .
|
||||
#
|
||||
# wizard-full bakes every pinned binary in at build time. `docker save` it and
|
||||
# you have the whole installer as one file to carry into an air-gapped network.
|
||||
|
||||
FROM debian:trixie-slim AS wizard
|
||||
|
||||
# ca-certificates + curl: fetch and verify. graphviz + python3: render diagrams
|
||||
# and validate the arch model, so the host never needs an apt package.
|
||||
#
|
||||
# docker-cli, NOT docker.io: we only ever talk to the host's daemon through the
|
||||
# mounted socket, and under --no-install-recommends the docker.io package ships
|
||||
# docker-init without the actual `docker` binary.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl jq graphviz python3 docker-cli \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /work
|
||||
COPY ctrl/versions.env /work/ctrl/versions.env
|
||||
COPY ctrl/wizard.sh /work/ctrl/wizard.sh
|
||||
RUN chmod +x /work/ctrl/wizard.sh
|
||||
|
||||
# Defaults; every one is overridable with -e at run time.
|
||||
ENV DEPS_SOURCE=upstream \
|
||||
OUT_BIN=/out/bin \
|
||||
HOST_ROOT=/host
|
||||
|
||||
ENTRYPOINT ["/work/ctrl/wizard.sh"]
|
||||
CMD ["install"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# wizard-full — same wizard, binaries baked in, works with no network at all.
|
||||
FROM wizard AS wizard-full
|
||||
RUN /work/ctrl/wizard.sh fetch --to /opt/rig/bin
|
||||
ENV DEPS_SOURCE=baked \
|
||||
BAKED_BIN=/opt/rig/bin
|
||||
67
rig/ctrl/Tiltfile
Normal file
67
rig/ctrl/Tiltfile
Normal 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')
|
||||
@@ -1,35 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install the addons the active profile asked for, in the order listed.
|
||||
# Each addon is its own idempotent script in ctrl/addons/ — adding one is adding
|
||||
# a file, not editing a dispatcher.
|
||||
#
|
||||
# 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 in profile '$PROFILE_NAME'"
|
||||
echo "no addons asked for (ADDONS is empty)"
|
||||
return
|
||||
fi
|
||||
local a
|
||||
local a p
|
||||
for a in $ADDONS; do
|
||||
if [ ! -f "addons/${a}.sh" ]; then
|
||||
echo "no such addon: addons/${a}.sh" >&2
|
||||
p=$(addon_path "$a")
|
||||
if [ -z "$p" ]; then
|
||||
echo "no such addon: $a (looked in the overlay's addons/ and ctrl/addons/)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "addon: $a"
|
||||
bash "addons/${a}.sh"
|
||||
bash "$p"
|
||||
done
|
||||
}
|
||||
|
||||
list() {
|
||||
echo "profile '$PROFILE_NAME' wants: ${ADDONS:-none}"
|
||||
echo "wanted: ${ADDONS:-none}"
|
||||
echo "available:"
|
||||
ls addons/*.sh 2>/dev/null | xargs -n1 basename | sed 's/\.sh$//' | sed 's/^/ /'
|
||||
local f
|
||||
if [ -n "$OVERLAY_DIR" ]; then
|
||||
for f in "$(_from_ctrl "$OVERLAY_DIR")"/addons/*.sh; do
|
||||
[ -e "$f" ] || continue
|
||||
printf ' %-16s overlay\n' "$(basename "$f" .sh)"
|
||||
done
|
||||
fi
|
||||
for f in addons/*.sh; do
|
||||
[ -e "$f" ] || continue
|
||||
printf ' %-16s rig\n' "$(basename "$f" .sh)"
|
||||
done
|
||||
}
|
||||
|
||||
case "${1:-list}" in
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Apache Airflow — the cluster half of soleprint's airflow cabinet.
|
||||
#
|
||||
# Airflow needs a metadata database before it will start at all, so this refuses
|
||||
# rather than rolls a pod that will CrashLoopBackOff while the real problem
|
||||
# (postgres missing from ADDONS) stays invisible in the logs.
|
||||
#
|
||||
# One pod on `standalone`, matching the compose cabinet: migration, admin user,
|
||||
# scheduler and webserver in a single container. The official chart's five
|
||||
# deployments model an installation; a room switching this on wants pipelines.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
source ./lib/config.sh
|
||||
load_config
|
||||
|
||||
K="kubectl --context ${KUBECONTEXT}"
|
||||
NS="${DATA_NAMESPACE:-data}"
|
||||
|
||||
if ! $K get deployment -n "$NS" postgres >/dev/null 2>&1; then
|
||||
echo " ! airflow needs the postgres addon, and it is not installed" >&2
|
||||
echo " add it before airflow in the profile's ADDONS:" >&2
|
||||
echo " ADDONS=\"... postgres airflow\"" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Reuse the credential postgres generated rather than storing a second copy.
|
||||
db_user=$($K get secret -n "$NS" postgres -o jsonpath='{.data.POSTGRES_USER}' | base64 -d)
|
||||
db_pass=$($K get secret -n "$NS" postgres -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d)
|
||||
db_name=$($K get secret -n "$NS" postgres -o jsonpath='{.data.POSTGRES_DB}' | base64 -d)
|
||||
|
||||
if $K get secret -n "$NS" airflow >/dev/null 2>&1; then
|
||||
echo " secret exists, keeping the current admin password and fernet key"
|
||||
else
|
||||
admin_password=$(head -c 18 /dev/urandom | base64 | tr -d '/+=' | head -c 24)
|
||||
# Airflow requires a 32-byte urlsafe-base64 key; without a fixed one every
|
||||
# restart invalidates every stored connection.
|
||||
fernet_key=$(head -c 32 /dev/urandom | base64 | tr '+/' '-_')
|
||||
$K create secret generic airflow -n "$NS" \
|
||||
--from-literal=ADMIN_USER="${AIRFLOW_ADMIN_USER:-admin}" \
|
||||
--from-literal=ADMIN_PASSWORD="$admin_password" \
|
||||
--from-literal=FERNET_KEY="$fernet_key" \
|
||||
--from-literal=SQL_ALCHEMY_CONN="postgresql+psycopg2://${db_user}:${db_pass}@postgres:5432/${db_name}" \
|
||||
>/dev/null
|
||||
echo " generated an admin password (read it back with the command below)"
|
||||
fi
|
||||
|
||||
echo " applying manifests"
|
||||
$K apply -n "$NS" -f - >/dev/null <<YAML
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: airflow
|
||||
spec:
|
||||
selector:
|
||||
app: airflow
|
||||
ports:
|
||||
- port: 8080
|
||||
targetPort: 8080
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: airflow
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: airflow
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: airflow
|
||||
spec:
|
||||
containers:
|
||||
- name: airflow
|
||||
image: ${AIRFLOW_IMAGE}
|
||||
args: ["standalone"]
|
||||
env:
|
||||
- name: AIRFLOW__CORE__EXECUTOR
|
||||
value: LocalExecutor
|
||||
- name: AIRFLOW__CORE__LOAD_EXAMPLES
|
||||
value: "false"
|
||||
- name: AIRFLOW__DATABASE__SQL_ALCHEMY_CONN
|
||||
valueFrom:
|
||||
secretKeyRef: {name: airflow, key: SQL_ALCHEMY_CONN}
|
||||
- name: AIRFLOW__CORE__FERNET_KEY
|
||||
valueFrom:
|
||||
secretKeyRef: {name: airflow, key: FERNET_KEY}
|
||||
- name: _AIRFLOW_WWW_USER_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef: {name: airflow, key: ADMIN_USER}
|
||||
- name: _AIRFLOW_WWW_USER_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef: {name: airflow, key: ADMIN_PASSWORD}
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8080
|
||||
# First boot runs the whole migration before it serves anything.
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 15
|
||||
failureThreshold: 20
|
||||
YAML
|
||||
|
||||
echo " waiting for airflow (the first boot migrates the database, so this is slow)..."
|
||||
$K rollout status deployment/airflow -n "$NS" --timeout=600s
|
||||
|
||||
echo " in-cluster: http://airflow.${NS}.svc.cluster.local:8080"
|
||||
echo " reach it: kubectl --context ${KUBECONTEXT} -n ${NS} port-forward svc/airflow 8080:8080"
|
||||
echo " password: kubectl --context ${KUBECONTEXT} -n ${NS} get secret airflow -o jsonpath='{.data.ADMIN_PASSWORD}' | base64 -d"
|
||||
@@ -1,12 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# cert-manager plus a self-signed cluster issuer.
|
||||
#
|
||||
# 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.
|
||||
# cert-manager plus a self-signed cluster issuer (offline local CA).
|
||||
# Notes: docs/notes/addons.md
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
cd "${RIG_CTRL:-$(dirname "$0")/..}"
|
||||
|
||||
source ./lib/config.sh
|
||||
load_config
|
||||
@@ -16,7 +12,9 @@ K="kubectl --context ${KUBECONTEXT}"
|
||||
if $K get deployment -n cert-manager cert-manager >/dev/null 2>&1; then
|
||||
echo " already installed"
|
||||
else
|
||||
$K apply -f "https://github.com/cert-manager/cert-manager/releases/download/${CERT_MANAGER_VERSION}/cert-manager.yaml"
|
||||
# The pinned manifest, verified on disk — never a URL applied directly.
|
||||
manifest=$(bash ./deps.sh manifest CERT_MANAGER)
|
||||
$K apply -f "$manifest"
|
||||
fi
|
||||
|
||||
echo " waiting for cert-manager..."
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# MetalLB — makes `Service type: LoadBalancer` actually get an address.
|
||||
#
|
||||
# Why it matters here: real manifests use LoadBalancer, because a real cluster
|
||||
# has one. On a bare kind cluster those Services sit at EXTERNAL-IP <pending>
|
||||
# forever with no error anywhere — the deployment looks fine and simply is not
|
||||
# reachable. Without this, every such Service has to be edited to NodePort,
|
||||
# which means the local manifests stop matching the ones being modelled.
|
||||
#
|
||||
# The address pool is derived from the kind Docker network at install time, not
|
||||
# hardcoded: Docker picks that subnet, it differs between machines, and a pool
|
||||
# outside it is silently unroutable.
|
||||
# The pool is derived from the kind Docker network at install time.
|
||||
# Notes: docs/notes/addons.md
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
ADDON_DIR="$(cd "$(dirname "$0")" && pwd)" # its manifest sits beside it
|
||||
cd "${RIG_CTRL:-$(dirname "$0")/..}"
|
||||
|
||||
source ./lib/config.sh
|
||||
load_config
|
||||
@@ -54,13 +47,12 @@ echo " kind network $subnet → pool ${pool_start}-${pool_end}"
|
||||
if $K get deployment -n metallb-system controller >/dev/null 2>&1; then
|
||||
echo " already installed"
|
||||
else
|
||||
$K apply -f "https://raw.githubusercontent.com/metallb/metallb/${METALLB_VERSION}/config/manifests/metallb-native.yaml"
|
||||
# The pinned manifest, verified on disk — never a URL applied directly.
|
||||
manifest=$(bash ./deps.sh manifest METALLB)
|
||||
$K apply -f "$manifest"
|
||||
fi
|
||||
|
||||
# `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.
|
||||
# `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
|
||||
@@ -70,27 +62,7 @@ $K rollout status daemonset/speaker -n metallb-system --timeout=240s
|
||||
# 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
|
||||
if render_manifest "$ADDON_DIR/metallb.yaml" | $K apply -f - >/dev/null 2>&1
|
||||
then
|
||||
echo " pool ready: ${pool_start}-${pool_end}"
|
||||
exit 0
|
||||
|
||||
19
rig/ctrl/addons/metallb.yaml
Normal file
19
rig/ctrl/addons/metallb.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
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
|
||||
@@ -1,12 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# metrics-server — makes `kubectl top` work.
|
||||
#
|
||||
# kind nodes serve kubelet metrics over a self-signed cert, so the standard
|
||||
# manifest never becomes ready without --kubelet-insecure-tls. That is fine here
|
||||
# (it is a local cluster) and is the single most common reason metrics-server
|
||||
# sits at 0/1 on kind.
|
||||
# metrics-server — makes `kubectl top` work (patched with --kubelet-insecure-tls for kind).
|
||||
# Notes: docs/notes/addons.md
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
cd "${RIG_CTRL:-$(dirname "$0")/..}"
|
||||
|
||||
source ./lib/config.sh
|
||||
load_config
|
||||
@@ -14,7 +10,9 @@ load_config
|
||||
K="kubectl --context ${KUBECONTEXT}"
|
||||
|
||||
if ! $K get deployment -n kube-system metrics-server >/dev/null 2>&1; then
|
||||
$K apply -f "https://github.com/kubernetes-sigs/metrics-server/releases/download/${METRICS_SERVER_VERSION}/components.yaml"
|
||||
# The pinned manifest, verified on disk — never a URL applied directly.
|
||||
manifest=$(bash ./deps.sh manifest METRICS_SERVER)
|
||||
$K apply -f "$manifest"
|
||||
fi
|
||||
|
||||
$K patch deployment metrics-server -n kube-system --type=json \
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# PostgreSQL — the cluster half of soleprint's postgres cabinet.
|
||||
#
|
||||
# A room declares the dependency once, in cfg/<room>/data/cabinets.json. On a
|
||||
# laptop `build.py` composes it into docker-compose.yml; here it becomes a pod,
|
||||
# so the same declaration works either way and nothing has to be remembered
|
||||
# twice.
|
||||
#
|
||||
# Plain manifests rather than a helm chart, matching the other addons: a chart
|
||||
# repo is a network dependency, and the offline profile exists precisely so
|
||||
# there is a path with none. The image is pinned in ctrl/versions.env and can be
|
||||
# preloaded into a local registry like every other image here.
|
||||
#
|
||||
# One replica on a PVC. This models a dependency for local work, not a
|
||||
# highly-available database, and pretending otherwise on a kind node would be a
|
||||
# more elaborate lie rather than a more useful one.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
source ./lib/config.sh
|
||||
load_config
|
||||
|
||||
K="kubectl --context ${KUBECONTEXT}"
|
||||
NS="${DATA_NAMESPACE:-data}"
|
||||
|
||||
$K get namespace "$NS" >/dev/null 2>&1 || $K create namespace "$NS"
|
||||
|
||||
# The password is generated once and then left alone, so re-running this does
|
||||
# not rotate the credential out from under whatever is already connected.
|
||||
if $K get secret -n "$NS" postgres >/dev/null 2>&1; then
|
||||
echo " secret exists, keeping the current password"
|
||||
else
|
||||
password=$(head -c 18 /dev/urandom | base64 | tr -d '/+=' | head -c 24)
|
||||
$K create secret generic postgres -n "$NS" \
|
||||
--from-literal=POSTGRES_DB="${POSTGRES_DB:-soleprint}" \
|
||||
--from-literal=POSTGRES_USER="${POSTGRES_USER:-soleprint}" \
|
||||
--from-literal=POSTGRES_PASSWORD="$password" >/dev/null
|
||||
echo " generated a password (read it back with the command printed below)"
|
||||
fi
|
||||
|
||||
echo " applying manifests"
|
||||
$K apply -n "$NS" -f - >/dev/null <<YAML
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: postgres-data
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
resources:
|
||||
requests:
|
||||
storage: ${POSTGRES_STORAGE:-2Gi}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: postgres
|
||||
spec:
|
||||
selector:
|
||||
app: postgres
|
||||
ports:
|
||||
- port: 5432
|
||||
targetPort: 5432
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: postgres
|
||||
spec:
|
||||
replicas: 1
|
||||
# One volume, one writer. Rolling would start a second pod against the same
|
||||
# PVC before the first exits, and Postgres refuses to share a data directory.
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: ${POSTGRES_IMAGE}
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: postgres
|
||||
env:
|
||||
# The image initialises into the volume root otherwise, and a
|
||||
# lost+found from the PVC makes it refuse to initdb.
|
||||
- name: PGDATA
|
||||
value: /var/lib/postgresql/data/pgdata
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["sh", "-c", "pg_isready -U \$POSTGRES_USER -d \$POSTGRES_DB"]
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["sh", "-c", "pg_isready -U \$POSTGRES_USER -d \$POSTGRES_DB"]
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 15
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: postgres-data
|
||||
YAML
|
||||
|
||||
echo " waiting for postgres..."
|
||||
$K rollout status deployment/postgres -n "$NS" --timeout=240s
|
||||
|
||||
echo " in-cluster: postgres.${NS}.svc.cluster.local:5432"
|
||||
echo " password: kubectl --context ${KUBECONTEXT} -n ${NS} get secret postgres -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d"
|
||||
@@ -1,60 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Redis — the cluster half of soleprint's redis cabinet.
|
||||
#
|
||||
# Cache, and the broker anything queue-shaped runs on. No persistence: a broker
|
||||
# that loses its queue on restart is the honest local model, and a PVC here buys
|
||||
# nothing but a volume to clean up.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
source ./lib/config.sh
|
||||
load_config
|
||||
|
||||
K="kubectl --context ${KUBECONTEXT}"
|
||||
NS="${DATA_NAMESPACE:-data}"
|
||||
|
||||
$K get namespace "$NS" >/dev/null 2>&1 || $K create namespace "$NS"
|
||||
|
||||
echo " applying manifests"
|
||||
$K apply -n "$NS" -f - >/dev/null <<YAML
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis
|
||||
spec:
|
||||
selector:
|
||||
app: redis
|
||||
ports:
|
||||
- port: 6379
|
||||
targetPort: 6379
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: redis
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: ${REDIS_IMAGE}
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["redis-cli", "ping"]
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 5
|
||||
YAML
|
||||
|
||||
echo " waiting for redis..."
|
||||
$K rollout status deployment/redis -n "$NS" --timeout=180s
|
||||
|
||||
echo " in-cluster: redis://redis.${NS}.svc.cluster.local:6379/0"
|
||||
312
rig/ctrl/check.sh
Executable file
312
rig/ctrl/check.sh
Executable file
@@ -0,0 +1,312 @@
|
||||
#!/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)
|
||||
# Prints the memory budget and what to do next first, then the configuration as it is.
|
||||
# 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
|
||||
|
||||
# Facts only with `all`; problems (!) always print.
|
||||
VERBOSE=""
|
||||
if [ "${1:-}" = all ]; then VERBOSE=1; fi
|
||||
fact() { if [ -n "$VERBOSE" ]; then echo "$@"; fi; }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
# Who listens on a port, as "pid name", when ss can tell (only for our own processes).
|
||||
port_owner() {
|
||||
command -v ss >/dev/null 2>&1 || return 0
|
||||
ss -ltnpH "sport = :$1" 2>/dev/null \
|
||||
| sed -n 's/.*users:(("\([^"]*\)",pid=\([0-9]*\).*/\2 \1/p' | head -1
|
||||
}
|
||||
|
||||
# This environment's Tilt: a tilt started from this ctrl/ (the Makefile runs it there).
|
||||
tilt_pid=""
|
||||
if tilt_owner=$(port_owner "$TILT_PORT") && [ -n "$tilt_owner" ]; then
|
||||
pid=${tilt_owner%% *}
|
||||
if [ "${tilt_owner#* }" = tilt ] && [ "$(readlink "/proc/$pid/cwd" 2>/dev/null)" = "$(pwd -P)" ]; then
|
||||
tilt_pid=$pid
|
||||
fi
|
||||
fi
|
||||
|
||||
# none | stopped | up | unknown (no docker)
|
||||
cluster_state() {
|
||||
docker info >/dev/null 2>&1 || { echo unknown; return; }
|
||||
local f="label=io.x-k8s.kind.cluster=${CLUSTER}"
|
||||
if [ -n "$(docker ps -q --filter "$f")" ]; then echo up
|
||||
elif [ -n "$(docker ps -aq --filter "$f")" ]; then echo stopped
|
||||
else echo none; fi
|
||||
}
|
||||
state=$(cluster_state)
|
||||
|
||||
# ── memory budget: what this environment needs, against what the machine has ──
|
||||
# The workload's share comes from the overlay: MEMORY_BUDGET="name=MB ..." in its rig.env,
|
||||
# and MEMORY_BUDGET_TIP, optionally, says how to switch its largest part off.
|
||||
# rig only knows what a node costs. 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 '?')
|
||||
node_mb=$(( NODES * NODE_MB ))
|
||||
|
||||
budget="" work_mb=0 big_mb=0 big=""
|
||||
for item in ${MEMORY_BUDGET:-}; do
|
||||
name=${item%=*} mb=${item##*=}
|
||||
if [ "$name" = "$item" ] || ! [[ "$mb" =~ ^[0-9]+$ ]]; then
|
||||
echo " ! budget '$item' in MEMORY_BUDGET is not name=MB"
|
||||
continue
|
||||
fi
|
||||
[ "$mb" -gt 0 ] || continue
|
||||
budget+=$(printf ' %-18s %6d MB' "$name" "$mb")$'\n'
|
||||
work_mb=$(( work_mb + mb ))
|
||||
if [ "$mb" -gt "$big_mb" ]; then big_mb=$mb big=$name; fi
|
||||
done
|
||||
need_mb=$(( work_mb + node_mb ))
|
||||
|
||||
rows=$(container_mb)
|
||||
# If our cluster is already up, its memory is already out of MemAvailable.
|
||||
ours_mb=$(awk -F'\t' -v c="$CLUSTER" '$2 == c { s += $1 } END { print s + 0 }' <<< "$rows")
|
||||
if [ "$state" = up ]; then
|
||||
still_mb=$(( need_mb > ours_mb ? need_mb - ours_mb : 0 ))
|
||||
else
|
||||
still_mb=$need_mb
|
||||
fi
|
||||
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)
|
||||
|
||||
echo
|
||||
echo "memory budget (${CLUSTER})"
|
||||
printf '%s' "$budget"
|
||||
if [ -z "$budget" ]; then
|
||||
echo " workload ? (the overlay lists none: MEMORY_BUDGET in its rig.env)"
|
||||
fi
|
||||
printf ' %-18s %6d MB (kind control plane, kube-system; %s node(s))\n' "node overhead" "$node_mb" "$NODES"
|
||||
echo " ---------"
|
||||
printf ' %-18s %6d MB\n' "required" "$need_mb"
|
||||
if [ "$state" = up ]; then
|
||||
printf ' %-18s %6d MB (%s is up)\n' "held by it now" "$ours_mb" "$CLUSTER"
|
||||
fi
|
||||
printf ' %-18s %6d MB\n' "this machine has" "$total_mb"
|
||||
printf ' %-18s %6d MB (right now)\n' "available" "$avail_mb"
|
||||
echo
|
||||
|
||||
if [ "$headroom" -ge 512 ]; then
|
||||
if [ "$state" = up ]; then
|
||||
echo "fits: ${CLUSTER} holds ${ours_mb} MB of its ${need_mb} MB; ${avail_mb} MB still available — ${headroom} MB headroom."
|
||||
else
|
||||
echo "fits: ${need_mb} MB needed, ${avail_mb} MB available — ${headroom} MB headroom."
|
||||
fi
|
||||
elif [ "$headroom" -ge 0 ]; then
|
||||
echo "! tight: ${still_mb} MB still needed, ${avail_mb} MB available — only ${headroom} MB headroom."
|
||||
else
|
||||
echo "! does not fit: ${still_mb} MB still needed, ${avail_mb} MB available."
|
||||
# Two failures with opposite fixes, and telling them apart is the point.
|
||||
if [ "$need_mb" -le "$total_mb" ]; then
|
||||
echo " Something else holds the memory (below): stopping it helps, a bigger machine 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 in all. Give it more memory"
|
||||
echo " ('make check mem' has the WSL steps), or switch parts of the workload off."
|
||||
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
|
||||
# One part that outweighs the rest is the first thing to switch off on a smaller machine.
|
||||
if [ "$work_mb" -gt 0 ] && [ $(( big_mb * 2 )) -gt "$work_mb" ] && [ "$big_mb" -ge 1024 ]; then
|
||||
echo
|
||||
echo "${big} is ${big_mb} of the ${work_mb} MB the workload needs — more than the rest"
|
||||
echo "combined. If you are not working on it, ${MEMORY_BUDGET_TIP:-switching it off in the overlay} gets that back."
|
||||
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
|
||||
|
||||
# ── where things stand, and the next step ──────────────────────────────────
|
||||
echo
|
||||
case "$state" in
|
||||
none)
|
||||
echo "${CLUSTER} does not exist yet. 'make cluster up' creates it${ADDONS:+ (with ${ADDONS})},"
|
||||
echo "then 'make tilt' starts the dev loop." ;;
|
||||
stopped)
|
||||
echo "${CLUSTER} exists but is stopped (nothing is lost). Start it again with:"
|
||||
echo " docker start \$(docker ps -aq -f label=io.x-k8s.kind.cluster=${CLUSTER})" ;;
|
||||
up)
|
||||
if [ -n "$tilt_pid" ]; then
|
||||
echo "${CLUSTER} is up, and its Tilt is running (pid ${tilt_pid}): http://localhost:${TILT_PORT}"
|
||||
else
|
||||
echo "${CLUSTER} is up. 'make tilt' starts the dev loop, at http://localhost:${TILT_PORT}."
|
||||
fi
|
||||
echo "Through the gateway: http://localhost:${HTTP_PORT}" ;;
|
||||
*)
|
||||
echo "docker is not reachable, so the cluster's state is unknown (the machine block below says why)." ;;
|
||||
esac
|
||||
|
||||
# ── the configuration, as it is ────────────────────────────────────────────
|
||||
echo
|
||||
bash ./deps.sh detect ${VERBOSE:+all}
|
||||
|
||||
echo
|
||||
echo "config"
|
||||
echo " cluster ${CLUSTER} ($([ "$state" = none ] && echo 'not created' || echo "$state"))"
|
||||
echo " context ${KUBECONTEXT}"
|
||||
if [ -n "${OVERLAY:-}" ]; then
|
||||
echo " overlay $(_abs_from_ctrl "$OVERLAY_DIR")"
|
||||
elif [ -n "$OVERLAY_DIR" ]; then
|
||||
echo " 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
|
||||
echo " profile ${PROFILE_NAME}"
|
||||
echo " nodes ${NODES}"
|
||||
echo " kind config ${KIND_CONFIG}"
|
||||
echo " manifests ${MANIFESTS_DIR:-none}"
|
||||
echo " 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
|
||||
|
||||
# ── ports: checked before creation; docker reports a clash only halfway through. ──
|
||||
# Ports held by our own cluster are not clashes: its node containers, and its registry,
|
||||
# which is a container of its own. Second grep, not `tr -d ':->'` (a tr range).
|
||||
ours=$({ docker ps --filter "label=io.x-k8s.kind.cluster=${CLUSTER}" --format '{{.Ports}}'
|
||||
docker ps --filter "name=^${CLUSTER}-registry\$" --format '{{.Ports}}'; } 2>/dev/null \
|
||||
| tr ',' '\n' | grep -oE ':[0-9]+->' | grep -oE '[0-9]+' || true)
|
||||
|
||||
clash=0 list=""
|
||||
for entry in "http:${HTTP_PORT}" "https:${HTTPS_PORT}" \
|
||||
"tilt:${TILT_PORT}" "registry:${REGISTRY_PORT}"; do
|
||||
name="${entry%%:*}"; p="${entry#*:}"
|
||||
[ -n "$p" ] || continue
|
||||
list+="$name $p "
|
||||
if ! port_busy "$p"; then
|
||||
fact "$(printf " %-9s %-6s free" "$name" "$p")"
|
||||
elif echo "$ours" | grep -qx "$p"; then
|
||||
fact "$(printf " %-9s %-6s in use by this environment's cluster" "$name" "$p")"
|
||||
elif [ "$name" = tilt ] && [ -n "$tilt_pid" ]; then
|
||||
fact "$(printf " %-9s %-6s in use by this environment's Tilt" "$name" "$p")"
|
||||
else
|
||||
owner=$(port_owner "$p")
|
||||
if [ "$name" = tilt ] && [ "${owner#* }" = tilt ]; then
|
||||
printf " ! ports tilt %s is held by another Tilt (pid %s, in %s)\n" "$p" "${owner%% *}" \
|
||||
"$(readlink "/proc/${owner%% *}/cwd" 2>/dev/null || echo '?')"
|
||||
echo " stop it (kill ${owner%% *}) before 'make tilt'"
|
||||
else
|
||||
printf " ! ports %s %s IN USE by %s\n" "$name" "$p" "${owner:+pid ${owner}}${owner:-something else}"
|
||||
clash=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo " ports ${list% }"
|
||||
if [ "$clash" -eq 1 ]; then
|
||||
echo " override it in ctrl/.env (e.g. HTTP_PORT=21080), or rename this directory"
|
||||
fi
|
||||
# ── what `make cluster up` wires in beside the cluster ─────────────────────
|
||||
REG_NAME="${CLUSTER}-registry"
|
||||
if reg_state=$(docker inspect -f '{{.State.Status}}' "$REG_NAME" 2>/dev/null); then
|
||||
echo " registry ${REGISTRY_MODE} localhost:${REGISTRY_PORT} (${reg_state})"
|
||||
else
|
||||
echo " registry ${REGISTRY_MODE} (no container yet)"
|
||||
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
|
||||
@@ -1,17 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Cluster lifecycle, plus what else is running on this machine.
|
||||
#
|
||||
# `list` and `free` live here 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" 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.
|
||||
#
|
||||
# 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")"
|
||||
|
||||
@@ -23,15 +13,15 @@ up() {
|
||||
echo "cluster '$CLUSTER' exists — converging"
|
||||
else
|
||||
# Say what this profile locks in BEFORE spending minutes building it:
|
||||
# the audit policy is an apiserver flag and cannot be changed later.
|
||||
# the kind config is fixed at creation and cannot be changed later.
|
||||
echo "creating cluster '$CLUSTER' from profile '$PROFILE_NAME'"
|
||||
echo " shape ctrl/k8s/$KIND_CONFIG"
|
||||
echo " overlay ${OVERLAY_DIR:-none}"
|
||||
echo " kind config ${KIND_CONFIG}"
|
||||
echo " nodes $NODES"
|
||||
echo " image $NODE_IMAGE"
|
||||
echo " audit $AUDIT"
|
||||
echo " ingress $INGRESS_MODE"
|
||||
echo " registry $REGISTRY_MODE"
|
||||
echo " (audit is fixed at creation — 'make cluster reset' to change it)"
|
||||
echo " (fixed at creation — edit the kind config, then 'make cluster reset')"
|
||||
echo
|
||||
|
||||
render_kind_config | kind create cluster --config -
|
||||
@@ -69,7 +59,7 @@ down() {
|
||||
}
|
||||
|
||||
# The escape hatch for a wedged cluster, and the only way to change a
|
||||
# creation-time setting such as the audit policy.
|
||||
# creation-time setting such as the node count or port mappings.
|
||||
reset() {
|
||||
down
|
||||
echo
|
||||
|
||||
848
rig/ctrl/deps.sh
Executable file
848
rig/ctrl/deps.sh
Executable 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
|
||||
@@ -1,272 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Share ONE Docker daemon across WSL distros, instead of running one per distro.
|
||||
#
|
||||
# Why this exists
|
||||
# ---------------
|
||||
# WSL2 distros share a kernel and a network stack. Two dockerd instances then
|
||||
# contend over docker0 and iptables, which can disturb the daemon you actually
|
||||
# depend on. Docker Desktop avoids this by running a single daemon in a
|
||||
# dedicated distro and sharing its socket — this is the same idea, without
|
||||
# Docker Desktop.
|
||||
#
|
||||
# So a throwaway rig box does NOT install Docker. It borrows the daemon from
|
||||
# whichever distro is the designated host. That also makes the test more honest:
|
||||
# rig never installs Docker anyway — Docker is its documented prerequisite.
|
||||
#
|
||||
# How
|
||||
# ---
|
||||
# /mnt/wsl is a tmpfs with `shared` mount propagation, visible to every distro
|
||||
# in the WSL VM. The owning distro exposes its socket there; guests point
|
||||
# DOCKER_HOST at it. Two ways, with different costs:
|
||||
#
|
||||
# share bind-mount the existing socket onto the shared tmpfs.
|
||||
# Instant, and dockerd is NEVER restarted. Lasts until the
|
||||
# next WSL shutdown.
|
||||
# share --persist additionally install a systemd drop-in so dockerd listens
|
||||
# there itself. Survives restarts, but requires one Docker
|
||||
# restart now — which stops every container that has no
|
||||
# restart policy, since live-restore is off by default.
|
||||
#
|
||||
# The bind mount is the default precisely because the persistent version's cost
|
||||
# is paid on a machine that is already working.
|
||||
#
|
||||
# Reversibility is the whole design
|
||||
# ---------------------------------
|
||||
# `unshare` removes the bind mount (no restart) and, if present, the drop-in.
|
||||
# The original systemd unit is never edited — only an additive drop-in file is
|
||||
# ever created — so undoing is deletion, not repair. `status` always states
|
||||
# which of the three roles a distro is in, in those words.
|
||||
#
|
||||
# Nothing here runs automatically. It does nothing until invoked.
|
||||
#
|
||||
# Usage:
|
||||
# dockerhost.sh status # which distro owns Docker; what this one uses
|
||||
# dockerhost.sh share # share it (bind mount, no daemon restart)
|
||||
# dockerhost.sh share --persist # ...and survive WSL restarts (restarts Docker)
|
||||
# dockerhost.sh unshare # undo it; this distro owns its Docker again
|
||||
# dockerhost.sh use [--persist] # point THIS distro at the shared socket
|
||||
set -euo pipefail
|
||||
|
||||
SHARED_DIR=/mnt/wsl/shared-docker
|
||||
SHARED_SOCK="$SHARED_DIR/docker.sock"
|
||||
OWNER_FILE="$SHARED_DIR/OWNER"
|
||||
DROPIN=/etc/systemd/system/docker.service.d/10-rig-shared-socket.conf
|
||||
PROFILE_D=/etc/profile.d/rig-docker-host.sh
|
||||
|
||||
distro_name() { echo "${WSL_DISTRO_NAME:-$(hostname)}"; }
|
||||
|
||||
require_wsl() {
|
||||
grep -qi microsoft /proc/version 2>/dev/null && return 0
|
||||
echo "dockerhost is WSL-only: it relies on /mnt/wsl being shared between distros." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── status ─────────────────────────────────────────────────────────────────
|
||||
|
||||
status() {
|
||||
require_wsl
|
||||
echo "distro $(distro_name)"
|
||||
|
||||
if [ -f "$DROPIN" ] || mountpoint -q "$SHARED_SOCK" 2>/dev/null; then
|
||||
echo "role SHARING — this distro's Docker is offered to other distros"
|
||||
elif [ -n "${DOCKER_HOST:-}" ] && [ "${DOCKER_HOST}" = "unix://$SHARED_SOCK" ]; then
|
||||
echo "role BORROWING — using another distro's Docker"
|
||||
else
|
||||
echo "role standalone — this WSL installation has the main host Docker"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ -S "$SHARED_SOCK" ]; then
|
||||
echo "shared sock $SHARED_SOCK (present)"
|
||||
[ -f "$OWNER_FILE" ] && sed 's/^/ /' "$OWNER_FILE"
|
||||
else
|
||||
echo "shared sock none — no distro is sharing right now"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "DOCKER_HOST ${DOCKER_HOST:-(unset — using /var/run/docker.sock)}"
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
echo "docker $(docker version --format '{{.Server.Version}}' 2>/dev/null || echo unreachable)"
|
||||
else
|
||||
echo "docker cli not installed"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── share / unshare (run on the host distro) ───────────────────────────────
|
||||
|
||||
# Default: expose the EXISTING socket by bind-mounting it onto the shared tmpfs.
|
||||
# /mnt/wsl has `shared` propagation, so the mount is visible in other distros.
|
||||
#
|
||||
# The point of doing it this way is that dockerd is never restarted. Restarting
|
||||
# it stops every container that has no restart policy (live-restore is off by
|
||||
# default), which on a working machine means quietly killing whatever you had
|
||||
# running. Not a trade worth making just to expose a socket.
|
||||
#
|
||||
# Cost: a bind mount does not survive a WSL VM shutdown. `--persist` adds the
|
||||
# systemd drop-in as well, which does survive but needs that one restart.
|
||||
share_bind() {
|
||||
mkdir -p "$SHARED_DIR"
|
||||
chmod 0755 "$SHARED_DIR"
|
||||
|
||||
if mountpoint -q "$SHARED_SOCK" 2>/dev/null; then
|
||||
echo "already bind-mounted at $SHARED_SOCK"
|
||||
else
|
||||
[ -S /var/run/docker.sock ] || { echo "no /var/run/docker.sock here" >&2; exit 1; }
|
||||
# The target must exist as a file for a bind mount onto it.
|
||||
[ -e "$SHARED_SOCK" ] || : > "$SHARED_SOCK"
|
||||
mount --bind /var/run/docker.sock "$SHARED_SOCK"
|
||||
echo "bind-mounted /var/run/docker.sock -> $SHARED_SOCK (no daemon restart)"
|
||||
fi
|
||||
|
||||
cat > "$OWNER_FILE" <<EOF
|
||||
owner distro: $(distro_name)
|
||||
docker gid: $(getent group docker | cut -d: -f3)
|
||||
socket: $SHARED_SOCK
|
||||
method: bind-mount (until the next WSL shutdown)
|
||||
EOF
|
||||
}
|
||||
|
||||
share() {
|
||||
require_wsl
|
||||
[ "$(id -u)" -eq 0 ] || { echo "run with sudo: sudo bash ctrl/dockerhost.sh share" >&2; exit 1; }
|
||||
|
||||
share_bind
|
||||
|
||||
if [ "${1:-}" != "--persist" ]; then
|
||||
echo
|
||||
echo "This lasts until the next WSL shutdown. To make it survive, re-run with"
|
||||
echo "--persist — but note that adds a systemd drop-in and RESTARTS Docker,"
|
||||
echo "which stops any container that has no restart policy."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -f "$DROPIN" ]; then
|
||||
echo "drop-in already present — sharing persists across restarts."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "--persist: installing a systemd drop-in and restarting Docker."
|
||||
echo "Containers without a restart policy will stop and will NOT come back."
|
||||
docker ps --format ' {{.Names}} restart={{.HostConfig.RestartPolicy.Name}}' 2>/dev/null \
|
||||
|| docker ps --format ' {{.Names}}' 2>/dev/null || true
|
||||
echo
|
||||
|
||||
local exec_line
|
||||
exec_line=$(systemctl cat docker.service | grep -m1 '^ExecStart=')
|
||||
if [ -z "$exec_line" ]; then
|
||||
echo "could not read docker.service ExecStart — refusing to guess" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$DROPIN")" "$SHARED_DIR"
|
||||
# Additive only: blank the inherited ExecStart, then restate it verbatim
|
||||
# with one extra -H. Nothing about the original unit is edited.
|
||||
cat > "$DROPIN" <<EOF
|
||||
# Added by rig (ctrl/dockerhost.sh share).
|
||||
#
|
||||
# Adds a SECOND listening socket on the WSL-shared tmpfs so other distros can
|
||||
# use this daemon instead of running their own. The original socket is
|
||||
# untouched, so this distro behaves exactly as before.
|
||||
#
|
||||
# To undo: sudo bash ctrl/dockerhost.sh unshare
|
||||
[Service]
|
||||
ExecStartPre=-/bin/mkdir -p $SHARED_DIR
|
||||
ExecStartPre=-/bin/chmod 0755 $SHARED_DIR
|
||||
ExecStart=
|
||||
${exec_line} -H unix://$SHARED_SOCK
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl restart docker
|
||||
|
||||
# Guests need a group with a MATCHING GID to use the socket; GIDs are not
|
||||
# consistent across distros, so record ours rather than assume.
|
||||
cat > "$OWNER_FILE" <<EOF
|
||||
owner distro: $(distro_name)
|
||||
docker gid: $(getent group docker | cut -d: -f3)
|
||||
socket: $SHARED_SOCK
|
||||
EOF
|
||||
|
||||
echo "sharing from '$(distro_name)'"
|
||||
echo " guests: export DOCKER_HOST=unix://$SHARED_SOCK"
|
||||
echo " undo: sudo bash ctrl/dockerhost.sh unshare"
|
||||
echo
|
||||
echo "NOTE: /mnt/wsl is tmpfs and is cleared when the WSL VM shuts down."
|
||||
echo " The drop-in recreates the directory on the next Docker start."
|
||||
}
|
||||
|
||||
unshare_() {
|
||||
require_wsl
|
||||
[ "$(id -u)" -eq 0 ] || { echo "run with sudo: sudo bash ctrl/dockerhost.sh unshare" >&2; exit 1; }
|
||||
|
||||
local did=0
|
||||
|
||||
# The bind mount first: undoing it needs no restart, so a plain `share`
|
||||
# is fully reversible without disturbing anything.
|
||||
if mountpoint -q "$SHARED_SOCK" 2>/dev/null; then
|
||||
umount "$SHARED_SOCK"
|
||||
rm -f "$SHARED_SOCK"
|
||||
echo " removed the bind mount (no restart needed)"
|
||||
did=1
|
||||
fi
|
||||
rm -f "$OWNER_FILE"
|
||||
rmdir "$SHARED_DIR" 2>/dev/null || true
|
||||
|
||||
if [ -f "$DROPIN" ]; then
|
||||
rm -f "$DROPIN"
|
||||
rmdir "$(dirname "$DROPIN")" 2>/dev/null || true
|
||||
systemctl daemon-reload
|
||||
systemctl restart docker
|
||||
echo " removed the systemd drop-in and restarted Docker"
|
||||
did=1
|
||||
fi
|
||||
|
||||
if [ "$did" -eq 0 ]; then
|
||||
echo "not sharing — this WSL installation already has the main host Docker."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "restored: this WSL installation has the main host Docker again."
|
||||
echo " (nothing else was changed; the original unit was never edited)"
|
||||
}
|
||||
|
||||
# ── use (run on a guest distro) ────────────────────────────────────────────
|
||||
|
||||
use() {
|
||||
require_wsl
|
||||
if [ ! -S "$SHARED_SOCK" ]; then
|
||||
echo "no shared socket at $SHARED_SOCK" >&2
|
||||
echo "Run 'sudo bash ctrl/dockerhost.sh share' in the distro that owns Docker." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Align the local docker group GID with the owner's, or the socket is
|
||||
# unreadable here even though it is visible.
|
||||
if [ -f "$OWNER_FILE" ] && [ "$(id -u)" -eq 0 ]; then
|
||||
local gid; gid=$(awk '/docker gid:/ {print $3}' "$OWNER_FILE")
|
||||
if [ -n "$gid" ]; then
|
||||
if getent group docker >/dev/null; then
|
||||
[ "$(getent group docker | cut -d: -f3)" = "$gid" ] || groupmod -g "$gid" docker
|
||||
else
|
||||
groupadd -g "$gid" docker
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${1:-}" = "--persist" ]; then
|
||||
[ "$(id -u)" -eq 0 ] || { echo "--persist needs root" >&2; exit 1; }
|
||||
echo "export DOCKER_HOST=unix://$SHARED_SOCK" > "$PROFILE_D"
|
||||
echo "persisted in $PROFILE_D"
|
||||
fi
|
||||
|
||||
echo "export DOCKER_HOST=unix://$SHARED_SOCK"
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
status) status ;;
|
||||
share) shift; share "${1:-}" ;;
|
||||
unshare) unshare_ ;;
|
||||
use) shift; use "${1:-}" ;;
|
||||
*) echo "usage: $0 [status|share|unshare|use [--persist]]" >&2; exit 1 ;;
|
||||
esac
|
||||
@@ -1,16 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Documentation: render the diagrams, and serve the pages.
|
||||
#
|
||||
# The docs are the instructions for building the cluster, so they must work
|
||||
# BEFORE anything else exists. That rules out serving them from the cluster, and
|
||||
# it rules out python -m http.server too — a minimal Debian has no python3. What
|
||||
# it does have, by definition, is Docker: the single prerequisite rig already
|
||||
# demands. So a throwaway nginx container serves a read-only bind mount.
|
||||
#
|
||||
# Rendered SVGs are committed alongside their .dot sources for the same reason:
|
||||
# the pages have to read on a machine with no Graphviz installed.
|
||||
#
|
||||
# Documentation: render the diagrams, and serve the pages from a throwaway nginx container.
|
||||
# Usage: docs.sh serve | graphs
|
||||
# Whose: DOCS_DIR, the docs/ of the folder `make docs` runs from (a project's launcher sets
|
||||
# it); unset, rig's own.
|
||||
# Notes: docs/notes/docs.md
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
@@ -18,20 +11,51 @@ source ./lib/config.sh
|
||||
load_config
|
||||
|
||||
REPO="$(cd .. && pwd)"
|
||||
DOCS="${DOCS_DIR:-$REPO/docs}"
|
||||
[ -d "$DOCS" ] && DOCS="$(cd "$DOCS" && 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
|
||||
if [ ! -f "$DOCS/index.html" ]; then
|
||||
echo "no index.html in $DOCS" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "docs for '$CLUSTER' on http://localhost:${DOCS_PORT}"
|
||||
# One worker and no access log: a few static pages need no more, and the
|
||||
# terminal then shows only real errors. The config is a temp file, gone on exit.
|
||||
# nginx runs as the caller, not as its own user: it reads what the caller can read,
|
||||
# whatever the umask (a 027 one leaves files unreadable to "others", which is a 403).
|
||||
# So no port below 1024 inside, and pid and temp files go to /tmp.
|
||||
# Global, not local: the trap runs after serve() has returned.
|
||||
DOCS_CONF=$(mktemp)
|
||||
trap 'rm -f "$DOCS_CONF"' EXIT
|
||||
cat > "$DOCS_CONF" <<'NGINX'
|
||||
worker_processes 1;
|
||||
error_log stderr error;
|
||||
pid /tmp/nginx.pid;
|
||||
events { worker_connections 64; }
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
access_log off;
|
||||
client_body_temp_path /tmp/client_body;
|
||||
proxy_temp_path /tmp/proxy;
|
||||
fastcgi_temp_path /tmp/fastcgi;
|
||||
uwsgi_temp_path /tmp/uwsgi;
|
||||
scgi_temp_path /tmp/scgi;
|
||||
server { listen 8080; root /usr/share/nginx/html; }
|
||||
}
|
||||
NGINX
|
||||
chmod 644 "$DOCS_CONF"
|
||||
echo "docs of $DOCS"
|
||||
echo " 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
|
||||
--user "$(id -u):$(id -g)" \
|
||||
--entrypoint nginx \
|
||||
-p "${DOCS_PORT}:8080" \
|
||||
-v "$DOCS:/usr/share/nginx/html:ro" \
|
||||
-v "$DOCS_CONF:/etc/nginx/nginx.conf:ro" \
|
||||
nginx:alpine -g 'daemon off;'
|
||||
}
|
||||
|
||||
graphs() {
|
||||
@@ -42,13 +66,13 @@ graphs() {
|
||||
fi
|
||||
shopt -s nullglob
|
||||
local found=0 f out
|
||||
for f in "$REPO"/docs/graphs/*.dot; do
|
||||
for f in "$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/"
|
||||
[ "$found" -eq 1 ] || echo " no .dot files in $DOCS/graphs/"
|
||||
}
|
||||
|
||||
case "${1:-serve}" in
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# client — the regulated-estate shape. Multi-node so taints, affinity and
|
||||
# topology are real; apiserver audit on; images through a pull-through cache of
|
||||
# the corporate registry.
|
||||
#
|
||||
# Costs roughly 4-6 GB. Check `make cluster list` before starting this alongside
|
||||
# other work — see the memory note in the README.
|
||||
|
||||
PROFILE_NAME=client
|
||||
K8S_VERSION=v1_36
|
||||
KIND_CONFIG=kind-config.client.yaml.tpl
|
||||
ADDONS="metallb cert-manager metrics-server"
|
||||
REGISTRY_MODE=mirror
|
||||
INGRESS_MODE=hostport
|
||||
DNS_MODE=hosts
|
||||
|
||||
# Ports derive from the directory name by default (see ctrl/ports.sh), so
|
||||
# several environments run side by side.
|
||||
#
|
||||
# Opt in to the real ports below only when this is the ONLY environment and
|
||||
# nothing else owns :80. They fail to bind otherwise, and docker reports it as an
|
||||
# opaque "failed to bind host port 0.0.0.0:80/tcp: address already in use"
|
||||
# halfway through cluster creation. `make station` checks before you spend the
|
||||
# time. Uncommenting also means only one environment can exist at a time.
|
||||
# HTTP_PORT=80
|
||||
# HTTPS_PORT=443
|
||||
|
||||
# Set these in ctrl/.env (gitignored), not here:
|
||||
# REGISTRY_REMOTE_URL=https://artifactory.corp.example/artifactory/api/docker/docker-virtual
|
||||
# REGISTRY_USER / REGISTRY_PASSWORD
|
||||
# REGISTRY_CA_FILE=/path/to/corp-root-ca.crt
|
||||
@@ -1,42 +0,0 @@
|
||||
# data — a cluster with the dependency containers a soleprint room asks for.
|
||||
#
|
||||
# The point of this profile is that a room declares what it needs once, in
|
||||
# cfg/<room>/data/cabinets.json, and gets it on either target: `build.py`
|
||||
# composes those services into docker-compose.yml for a laptop, and the addons
|
||||
# below install the same ones here. The names match deliberately —
|
||||
# soleprint/station/cabinets/<name>/cabinet.json carries a `rig_addon` field
|
||||
# pointing at ctrl/addons/<name>.sh.
|
||||
#
|
||||
# Everything lands in the `data` namespace (DATA_NAMESPACE to move it), so
|
||||
# `make cluster reset` on the app namespace leaves the databases alone.
|
||||
#
|
||||
# Costs roughly 2-3 GB with airflow, under 1 without. Airflow's first boot runs
|
||||
# the whole metadata migration, so expect a few minutes before it is ready.
|
||||
|
||||
PROFILE_NAME=data
|
||||
K8S_VERSION=v1_36
|
||||
KIND_CONFIG=kind-config.yaml.tpl
|
||||
# Order matters: addons.sh installs in the order listed, and airflow refuses to
|
||||
# start without a metadata database, so postgres comes first.
|
||||
ADDONS="metallb postgres redis airflow"
|
||||
# local, not none — see minimal.env: `none` has no outward-push guard.
|
||||
REGISTRY_MODE=local
|
||||
INGRESS_MODE=hostport
|
||||
DNS_MODE=hosts
|
||||
|
||||
# Namespace for the dependency containers.
|
||||
DATA_NAMESPACE=data
|
||||
|
||||
# Postgres identity. The password is not here: postgres.sh generates one on
|
||||
# first install and keeps it across re-runs, so re-running the addon never
|
||||
# rotates the credential out from under whatever is already connected.
|
||||
POSTGRES_DB=soleprint
|
||||
POSTGRES_USER=soleprint
|
||||
POSTGRES_STORAGE=2Gi
|
||||
|
||||
AIRFLOW_ADMIN_USER=admin
|
||||
|
||||
# Ports derive from the directory name by default — see ctrl/ports.sh. Reach
|
||||
# the databases with port-forward rather than binding more host ports:
|
||||
# kubectl -n data port-forward svc/postgres 5432:5432
|
||||
# kubectl -n data port-forward svc/airflow 8080:8080
|
||||
@@ -1,21 +0,0 @@
|
||||
# minimal — the default. One node, no addons, no registry.
|
||||
# Assumes nothing and boots fast. Start here; move to client.env when you need
|
||||
# the regulated behaviours.
|
||||
#
|
||||
|
||||
PROFILE_NAME=minimal
|
||||
K8S_VERSION=v1_36
|
||||
KIND_CONFIG=kind-config.yaml.tpl
|
||||
ADDONS=""
|
||||
# local, not none: `none` leaves the cluster with no registry to push to, and an
|
||||
# unqualified image name then means docker.io/library/<name>. In a regulated
|
||||
# estate that is a disclosure risk, not a convenience trade — so the default
|
||||
# carries the guard even though it costs one container.
|
||||
REGISTRY_MODE=local
|
||||
INGRESS_MODE=hostport
|
||||
DNS_MODE=hosts
|
||||
|
||||
# Ports are deliberately NOT set here. They derive from the directory name so
|
||||
# several environments coexist — see ctrl/ports.sh, and `make ports` to see the
|
||||
# block this one gets. A fixed default here would collide with whatever else the
|
||||
# machine happens to be running; 8080 in particular is rarely free.
|
||||
21
rig/ctrl/env.d/mirror.env.example
Normal file
21
rig/ctrl/env.d/mirror.env.example
Normal 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
|
||||
@@ -1,18 +0,0 @@
|
||||
# offline — air-gapped. Everything comes from a local registry that was loaded
|
||||
# ahead of time; nothing reaches the internet. Pair with the wizard-full image
|
||||
# (DEPS_SOURCE=baked) so the toolchain install is offline too.
|
||||
#
|
||||
# The heavier addons are left out to keep first boot viable.
|
||||
|
||||
PROFILE_NAME=offline
|
||||
K8S_VERSION=v1_36
|
||||
KIND_CONFIG=kind-config.audit.yaml.tpl
|
||||
ADDONS="metallb"
|
||||
REGISTRY_MODE=local
|
||||
INGRESS_MODE=hostport
|
||||
DNS_MODE=hosts
|
||||
|
||||
# Derived from the directory name by default — see ctrl/ports.sh.
|
||||
# Uncomment for the real ports, but only if this is the only environment.
|
||||
# HTTP_PORT=80
|
||||
# HTTPS_PORT=443
|
||||
15
rig/ctrl/env.d/offline.env.example
Normal file
15
rig/ctrl/env.d/offline.env.example
Normal 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
|
||||
@@ -1,15 +0,0 @@
|
||||
# /etc/hosts block for this environment. Rendered by newbox.sh; ${CLUSTER} and
|
||||
# ${HTTP_PORT} are substituted.
|
||||
#
|
||||
# Hostnames are a convenience, not a requirement — every service is reachable at
|
||||
# localhost:<port> without any of this, which is why DNS is not touched by
|
||||
# default. Add entries here as the model grows.
|
||||
#
|
||||
# On Windows the same block has to go in
|
||||
# C:\Windows\System32\drivers\etc\hosts for a browser to resolve these. That
|
||||
# file does NOT support wildcards, so every name must be listed explicitly.
|
||||
# newbox.sh prints the block for you to paste rather than editing it.
|
||||
|
||||
127.0.0.1 ${CLUSTER}.local
|
||||
127.0.0.1 api.${CLUSTER}.local
|
||||
127.0.0.1 docs.${CLUSTER}.local
|
||||
52
rig/ctrl/hosttest.sh
Executable file
52
rig/ctrl/hosttest.sh
Executable 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
101
rig/ctrl/installtest.sh
Executable 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"
|
||||
@@ -1,71 +0,0 @@
|
||||
# `ctrl/k8s` — cluster shape, and what runs on it
|
||||
|
||||
Same layout as every other project here (`unt`, `nvi`, `eth`, `mpr`, and
|
||||
soleprint's generated rooms): a kind config, a kustomize `base/`, and an
|
||||
`overlays/dev/` that patches it. See ALL `projects/templates/conventions.md`.
|
||||
|
||||
```
|
||||
kind-config*.yaml.tpl the cluster itself — nodes, ports, audit
|
||||
base/ the components, as plain manifests
|
||||
overlays/dev/ how this rig differs from the base
|
||||
audit-policy.yaml mounted into the apiserver by the audit shapes
|
||||
```
|
||||
|
||||
## Why the cluster config is a template
|
||||
|
||||
Every other project checks in a literal `kind-config.yaml`, because there is
|
||||
exactly one `unt` and one `nvi`. A rig is copied and renamed to make a second
|
||||
environment, and both the cluster name and the host port block follow the
|
||||
directory name — so a literal would make every copy collide on both.
|
||||
|
||||
`ctrl/cluster.sh` renders it with `sed`, substituting `${CLUSTER}`,
|
||||
`${NODE_IMAGE}`, `${HTTP_PORT}` and `${HOST_WORKDIR}`. Not `envsubst`: that is
|
||||
`gettext-base`, which a minimal Debian does not have, and Docker being the only
|
||||
prerequisite is the one promise rig makes.
|
||||
|
||||
**The chosen file is the source of truth for node count and audit.**
|
||||
`lib/config.sh` reads both back out of it, so a profile names a shape and does
|
||||
not restate what the YAML already says.
|
||||
|
||||
| file | nodes | audit | profiles |
|
||||
| --- | --- | --- | --- |
|
||||
| `kind-config.yaml.tpl` | 1 | off | `minimal`, `data` |
|
||||
| `kind-config.audit.yaml.tpl` | 1 | on | `offline` |
|
||||
| `kind-config.client.yaml.tpl` | 3 | on | `client` |
|
||||
|
||||
A profile picks one with `KIND_CONFIG` in `ctrl/env.d/<profile>.env`. Adding a
|
||||
shape is adding a file — there is no dispatcher to edit.
|
||||
|
||||
Audit is an apiserver flag and therefore fixed at creation: changing it is
|
||||
`make cluster reset`, not a re-apply.
|
||||
|
||||
## `base/` — replace these
|
||||
|
||||
**The two components in `base/` are examples, not the system.** They exist so
|
||||
the real manifests have a shape to be written against.
|
||||
|
||||
The real ones are expected to be versioned **separately from the installer** —
|
||||
they change on a different cadence, by different people, under different review.
|
||||
Point `MANIFESTS_DIR` in `ctrl/.env` at their overlay and rig stops owning them:
|
||||
|
||||
```
|
||||
MANIFESTS_DIR=../platform-manifests/overlays/dev
|
||||
```
|
||||
|
||||
Until then it defaults to `ctrl/k8s/overlays/dev`.
|
||||
|
||||
### The three states a component can be in
|
||||
|
||||
Switching between them should be a one-line change, never a rewrite. The DNS
|
||||
name stays the same in every case, so callers never know the difference:
|
||||
|
||||
| state | what exists | when |
|
||||
| --- | --- | --- |
|
||||
| **real** | an image built from source, hot-reloaded | the one thing you are working on |
|
||||
| **mock** | a stub returning canned responses (`example-mock.yaml`) | everything else — most of the estate |
|
||||
| **remote** | no pod at all, just a Service (`example-remote.yaml`) | when the real system is reachable and you want it |
|
||||
|
||||
Most components should be **mock**. What has to be faithful is the topology —
|
||||
names, ports, dependency order, who can reach whom, how it fails. The workloads
|
||||
are noise, and mocking them is what makes several copies of a large estate fit
|
||||
on one laptop.
|
||||
@@ -1,44 +0,0 @@
|
||||
# Apiserver audit policy. Mounted into the control plane at creation when a
|
||||
# profile sets AUDIT=on — an apiserver flag, so it cannot be added to a running
|
||||
# cluster without recreating it.
|
||||
#
|
||||
# Deliberately modest: enough to make "who changed what, and when" answerable
|
||||
# during onboarding without filling the disk. Read the log with:
|
||||
# docker exec <cluster>-control-plane cat /var/log/kubernetes/audit.log
|
||||
apiVersion: audit.k8s.io/v1
|
||||
kind: Policy
|
||||
|
||||
# Never log the request body for these — they contain credentials.
|
||||
omitStages:
|
||||
- RequestReceived
|
||||
|
||||
rules:
|
||||
# Secrets/configmaps: record that access happened, never the contents.
|
||||
- level: Metadata
|
||||
resources:
|
||||
- group: ""
|
||||
resources: ["secrets", "configmaps"]
|
||||
|
||||
# Authn/authz decisions — the part an auditor actually asks about.
|
||||
- level: Metadata
|
||||
nonResourceURLs:
|
||||
- /apis*
|
||||
- /api*
|
||||
|
||||
# Mutations to workloads and policy: full request, so a diff is reconstructable.
|
||||
- level: Request
|
||||
verbs: ["create", "update", "patch", "delete"]
|
||||
resources:
|
||||
- group: ""
|
||||
resources: ["pods", "services", "serviceaccounts", "namespaces"]
|
||||
- group: "apps"
|
||||
- group: "networking.k8s.io"
|
||||
- group: "rbac.authorization.k8s.io"
|
||||
|
||||
# Everything else that changes state: metadata only.
|
||||
- level: Metadata
|
||||
verbs: ["create", "update", "patch", "delete"]
|
||||
|
||||
# Reads are dropped entirely — otherwise controller polling drowns the log.
|
||||
- level: None
|
||||
verbs: ["get", "list", "watch"]
|
||||
@@ -1,57 +0,0 @@
|
||||
# Cluster shape: one node, apiserver audit ON. Used by the `offline` profile.
|
||||
#
|
||||
# Audit is an apiserver flag, so it is fixed when the cluster is created —
|
||||
# changing it means `make cluster reset`, not a re-apply. That is why it is a
|
||||
# property of the cluster file rather than something switched at runtime.
|
||||
#
|
||||
# k8s >= 1.31 uses kubeadm v1beta4, where extraArgs is a LIST of name/value
|
||||
# pairs. The older map form is silently ignored — it does not error, audit
|
||||
# simply never turns on.
|
||||
#
|
||||
# Substituted by ctrl/cluster.sh: CLUSTER, NODE_IMAGE, HTTP_PORT, HOST_WORKDIR
|
||||
# (named without the ${...} braces so this line survives the substitution)
|
||||
kind: Cluster
|
||||
apiVersion: kind.x-k8s.io/v1alpha4
|
||||
name: ${CLUSTER}
|
||||
|
||||
containerdConfigPatches:
|
||||
- |-
|
||||
[plugins."io.containerd.grpc.v1.cri".registry]
|
||||
config_path = "/etc/containerd/certs.d"
|
||||
|
||||
kubeadmConfigPatches:
|
||||
- |
|
||||
kind: ClusterConfiguration
|
||||
apiServer:
|
||||
extraArgs:
|
||||
- name: audit-policy-file
|
||||
value: /etc/kubernetes/audit/policy.yaml
|
||||
- name: audit-log-path
|
||||
value: /var/log/kubernetes/audit.log
|
||||
- name: audit-log-maxage
|
||||
value: "7"
|
||||
extraVolumes:
|
||||
- name: audit-policy
|
||||
hostPath: /etc/kubernetes/audit
|
||||
mountPath: /etc/kubernetes/audit
|
||||
readOnly: true
|
||||
- name: audit-log
|
||||
hostPath: /var/log/kubernetes
|
||||
mountPath: /var/log/kubernetes
|
||||
readOnly: false
|
||||
|
||||
nodes:
|
||||
- role: control-plane
|
||||
image: ${NODE_IMAGE}
|
||||
# hostPath is resolved by the HOST dockerd, so this must be a host path even
|
||||
# when cluster.sh runs inside the wizard container. HOST_WORKDIR says where
|
||||
# this rig lives on the host; bare on a host it is just the repo root.
|
||||
extraMounts:
|
||||
- hostPath: ${HOST_WORKDIR}/ctrl/k8s/audit-policy.yaml
|
||||
containerPath: /etc/kubernetes/audit/policy.yaml
|
||||
readOnly: true
|
||||
extraPortMappings:
|
||||
- containerPort: 30080
|
||||
hostPort: ${HTTP_PORT}
|
||||
listenAddress: "0.0.0.0"
|
||||
protocol: TCP
|
||||
@@ -1,55 +0,0 @@
|
||||
# Cluster shape: three nodes, apiserver audit ON. Used by the `client` profile —
|
||||
# the regulated-estate shape.
|
||||
#
|
||||
# Multi-node so taints, affinity and topology spread are real rather than
|
||||
# vacuously satisfied by a single node. It costs roughly 4-6 GB; run
|
||||
# `make cluster list` before starting this alongside other work.
|
||||
#
|
||||
# Substituted by ctrl/cluster.sh: CLUSTER, NODE_IMAGE, HTTP_PORT, HOST_WORKDIR
|
||||
# (named without the ${...} braces so this line survives the substitution)
|
||||
kind: Cluster
|
||||
apiVersion: kind.x-k8s.io/v1alpha4
|
||||
name: ${CLUSTER}
|
||||
|
||||
containerdConfigPatches:
|
||||
- |-
|
||||
[plugins."io.containerd.grpc.v1.cri".registry]
|
||||
config_path = "/etc/containerd/certs.d"
|
||||
|
||||
kubeadmConfigPatches:
|
||||
- |
|
||||
kind: ClusterConfiguration
|
||||
apiServer:
|
||||
extraArgs:
|
||||
- name: audit-policy-file
|
||||
value: /etc/kubernetes/audit/policy.yaml
|
||||
- name: audit-log-path
|
||||
value: /var/log/kubernetes/audit.log
|
||||
- name: audit-log-maxage
|
||||
value: "7"
|
||||
extraVolumes:
|
||||
- name: audit-policy
|
||||
hostPath: /etc/kubernetes/audit
|
||||
mountPath: /etc/kubernetes/audit
|
||||
readOnly: true
|
||||
- name: audit-log
|
||||
hostPath: /var/log/kubernetes
|
||||
mountPath: /var/log/kubernetes
|
||||
readOnly: false
|
||||
|
||||
nodes:
|
||||
- role: control-plane
|
||||
image: ${NODE_IMAGE}
|
||||
extraMounts:
|
||||
- hostPath: ${HOST_WORKDIR}/ctrl/k8s/audit-policy.yaml
|
||||
containerPath: /etc/kubernetes/audit/policy.yaml
|
||||
readOnly: true
|
||||
extraPortMappings:
|
||||
- containerPort: 30080
|
||||
hostPort: ${HTTP_PORT}
|
||||
listenAddress: "0.0.0.0"
|
||||
protocol: TCP
|
||||
- role: worker
|
||||
image: ${NODE_IMAGE}
|
||||
- role: worker
|
||||
image: ${NODE_IMAGE}
|
||||
@@ -1,23 +1,12 @@
|
||||
# Cluster shape: one node, no audit. Used by the `minimal` and `data` profiles.
|
||||
#
|
||||
# A TEMPLATE rather than a plain kind-config.yaml because a rig is copied and
|
||||
# renamed to make a second environment, and both the cluster name and the host
|
||||
# port follow the directory. A checked-in literal would make every copy collide
|
||||
# on both. ctrl/cluster.sh renders it with sed — not envsubst, which is
|
||||
# gettext-base and absent from a minimal Debian, and rig's whole premise is that
|
||||
# Docker is the only prerequisite.
|
||||
#
|
||||
# Substituted by ctrl/cluster.sh: CLUSTER, NODE_IMAGE, HTTP_PORT, HOST_WORKDIR
|
||||
# (named without the ${...} braces so this line survives the substitution)
|
||||
# Node count and audit are READ BACK from this file by lib/config.sh, so this
|
||||
# YAML is the source of truth for both — there is no second place to update.
|
||||
# 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}
|
||||
|
||||
# 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.
|
||||
# containerd reads per-host registry config from certs.d (written by registry.sh).
|
||||
containerdConfigPatches:
|
||||
- |-
|
||||
[plugins."io.containerd.grpc.v1.cri".registry]
|
||||
@@ -26,9 +15,7 @@ containerdConfigPatches:
|
||||
nodes:
|
||||
- role: control-plane
|
||||
image: ${NODE_IMAGE}
|
||||
# One NodePort bridged to the host; an in-cluster gateway owns it. There is
|
||||
# deliberately no ingress controller — they pin a narrow window of k8s
|
||||
# versions, and running a trailing-edge control plane is the point.
|
||||
# One NodePort bridged to the host, owned by an in-cluster gateway (no ingress controller).
|
||||
extraPortMappings:
|
||||
- containerPort: 30080
|
||||
hostPort: ${HTTP_PORT}
|
||||
|
||||
@@ -1,44 +1,40 @@
|
||||
# Shared config loading. Sourced, never executed.
|
||||
#
|
||||
# The ecosystem convention is that scripts are standalone with no shared log
|
||||
# library — that still holds. This file is not a logging lib; it is the single
|
||||
# definition of how the config layers compose, which every script has to agree
|
||||
# on exactly. Precedence, weakest first:
|
||||
#
|
||||
# ctrl/versions.env pinned toolchain + image digests (committed)
|
||||
# ctrl/env.d/<profile> cluster shape (committed)
|
||||
# ctrl/.env machine-local values and secrets (gitignored)
|
||||
# the caller's env `make cluster up PROFILE=client` (always wins)
|
||||
#
|
||||
# That last rule is why this is more than a few `source` lines: .env sets
|
||||
# PROFILE, so without snapshotting it would silently override the PROFILE the
|
||||
# user just typed on the command line.
|
||||
#
|
||||
# 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
|
||||
|
||||
# Values a user can reasonably override per-invocation. Anything set in the
|
||||
# environment when load_config runs is restored after the files are read.
|
||||
# NODES and AUDIT are deliberately NOT here: they are properties of the chosen
|
||||
# ctrl/k8s/kind-config*.yaml.tpl and are read back out of it below, so there is
|
||||
# one place that decides the shape of the cluster rather than two that can drift.
|
||||
CONFIG_OVERRIDABLE="PROFILE CLUSTER K8S_VERSION KIND_CONFIG ADDONS
|
||||
# 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"
|
||||
SOURCE ARCH DEPS_SOURCE HTTP_PORT HTTPS_PORT
|
||||
REGISTRY_PORT MANIFESTS_DIR"
|
||||
|
||||
# The containing folder's name, reduced to something kind accepts as a cluster
|
||||
# name (a DNS label: lowercase alphanumerics and dashes). Run from ctrl/, so the
|
||||
# repo root is the parent.
|
||||
# rig's own example, used when no overlay is named (relative to rig's root).
|
||||
DEFAULT_OVERLAY=examples/starter
|
||||
|
||||
# A rig-root-relative path as seen from ctrl/; absolute paths pass through.
|
||||
_from_ctrl() { case "$1" in /*) echo "$1" ;; *) echo "../$1" ;; esac; }
|
||||
|
||||
# The same, absolute. Empty if it does not exist.
|
||||
_abs_from_ctrl() { (cd "$(_from_ctrl "$1")" 2>/dev/null && pwd); }
|
||||
|
||||
# The environment's folder — the overlay's when one is named, else rig's —
|
||||
# reduced to a DNS label kind accepts as a cluster name.
|
||||
default_cluster_name() {
|
||||
local n
|
||||
n=$(basename "$(cd .. && pwd)")
|
||||
if [ -n "${OVERLAY:-}" ]; then
|
||||
n=$(basename "$(_abs_from_ctrl "$OVERLAY_DIR")")
|
||||
else
|
||||
n=$(basename "$(cd .. && pwd)")
|
||||
fi
|
||||
n=$(echo "$n" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-')
|
||||
n=$(echo "$n" | sed 's/^-*//; s/-*$//')
|
||||
echo "${n:-rig}"
|
||||
}
|
||||
|
||||
# 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.
|
||||
# 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))
|
||||
@@ -56,43 +52,119 @@ load_config() {
|
||||
|
||||
set -a
|
||||
source ./versions.env
|
||||
[ -f ./.env ] && source ./.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"
|
||||
|
||||
local profile="${PROFILE:-minimal}"
|
||||
if [ ! -f "./env.d/${profile}.env" ]; then
|
||||
echo "no such profile: env.d/${profile}.env" >&2
|
||||
echo "available: $(ls env.d/*.env 2>/dev/null | xargs -n1 basename | sed 's/\.env$//' | tr '\n' ' ')" >&2
|
||||
exit 1
|
||||
# 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
|
||||
|
||||
set -a
|
||||
source "./env.d/${profile}.env"
|
||||
[ -f ./.env ] && source ./.env
|
||||
set +a
|
||||
# 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
|
||||
|
||||
_config_restore "$saved"
|
||||
# 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 copying this directory somewhere else and
|
||||
# renaming it yields a distinct environment with no further edits. Without
|
||||
# this, two copies would share one cluster and `make cluster down` in either
|
||||
# would destroy the other's.
|
||||
# Identity follows the folder, so a renamed copy is a distinct environment.
|
||||
CLUSTER="${CLUSTER:-$(default_cluster_name)}"
|
||||
KUBECONTEXT="kind-${CLUSTER}"
|
||||
|
||||
# Host ports are a single shared namespace, so unlike the cluster name they
|
||||
# cannot just follow the directory — they have to be spread out. Anything
|
||||
# already set (ctrl/.env, a profile, the command line) wins; only the gaps
|
||||
# are filled. See ports.sh for the reasoning.
|
||||
# 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:-}"
|
||||
@@ -101,38 +173,62 @@ load_config() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The cluster's shape is a file in ctrl/k8s/, named by the profile. Adding a
|
||||
# shape is adding a file; there is no dispatcher to edit.
|
||||
KIND_CONFIG="${KIND_CONFIG:-kind-config.yaml.tpl}"
|
||||
KIND_CONFIG_PATH="./k8s/${KIND_CONFIG}"
|
||||
if [ ! -f "$KIND_CONFIG_PATH" ]; then
|
||||
echo "no such cluster shape: ctrl/k8s/${KIND_CONFIG}" >&2
|
||||
echo "available: $(ls k8s/kind-config*.yaml.tpl 2>/dev/null | xargs -n1 basename | tr '\n' ' ')" >&2
|
||||
# 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 shape back out of the YAML rather than trusting a profile to
|
||||
# restate it. station.sh sizes the memory warning on NODES, and cluster.sh
|
||||
# prints AUDIT before spending minutes building something that cannot be
|
||||
# changed afterwards — both would mislead if the numbers drifted.
|
||||
NODES=$(grep -c '^ - role:' "$KIND_CONFIG_PATH")
|
||||
if grep -q 'audit-policy-file' "$KIND_CONFIG_PATH"; then AUDIT=on; else AUDIT=off; fi
|
||||
# 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 a cluster shape to stdout. sed rather than envsubst: envsubst is
|
||||
# gettext-base, absent from a minimal Debian, and Docker is meant to be the only
|
||||
# prerequisite. The variable list is explicit so a template cannot quietly start
|
||||
# depending on something the caller does not set.
|
||||
#
|
||||
# hostPath entries are resolved by the HOST dockerd, so HOST_WORKDIR must stay a
|
||||
# host path even when this runs inside the wizard container.
|
||||
# Render 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)}"
|
||||
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" \
|
||||
"$KIND_CONFIG_PATH"
|
||||
-e "s|\${OVERLAY_DIR}|${overlay_dir}|g" \
|
||||
"$KIND_CONFIG"
|
||||
}
|
||||
|
||||
# Render an addon's manifest (a .yaml beside its .sh) to stdout. Every ${NAME}, or
|
||||
# ${NAME:-default}, is filled from the loaded config or the calling script's variables;
|
||||
# a NAME with neither is an error, so a typo never becomes an empty image. $VAR and
|
||||
# $(VAR) are left alone: they belong to the container. Notes: docs/notes/addons.md
|
||||
render_manifest() {
|
||||
local file="$1" text ref key def val
|
||||
text=$(<"$file") || return 1
|
||||
while IFS= read -r ref; do
|
||||
key=${ref#\$\{}; key=${key%\}}; def=""
|
||||
if [[ $key == *:-* ]]; then def=${key#*:-}; key=${key%%:-*}; fi
|
||||
if [ -n "${!key:-}" ]; then
|
||||
val=${!key}
|
||||
elif [[ $ref == *:-* ]]; then
|
||||
val=$def
|
||||
else
|
||||
echo "render_manifest: $file needs \${$key}, which nothing sets" >&2
|
||||
return 1
|
||||
fi
|
||||
text=${text//"$ref"/"$val"}
|
||||
done < <(grep -oE '\$\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\}' "$file" | sort -u)
|
||||
printf '%s\n' "$text"
|
||||
}
|
||||
|
||||
_config_restore() {
|
||||
@@ -146,3 +242,87 @@ _config_restore() {
|
||||
# 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
684
rig/ctrl/mem.sh
Executable 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
|
||||
@@ -1,313 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Create a disposable Linux environment to validate the installer from a
|
||||
# genuinely clean slate — one that can be thrown away without touching the
|
||||
# environment you actually work in.
|
||||
#
|
||||
# This is the ONLY host-aware file in the tree. Everything else needs just a
|
||||
# Linux with Docker, which is what keeps other host types a later addition
|
||||
# rather than a rewrite.
|
||||
#
|
||||
# On WSL it creates a second distro. There is no .bat and no PowerShell script:
|
||||
# wsl.exe is callable from inside WSL, and wslpath converts the paths it wants.
|
||||
# A machine with no WSL at all needs `wsl --install` run once by hand first —
|
||||
# scripting a reboot-requiring Windows feature install is not worth it.
|
||||
#
|
||||
# Docker: borrowed by default, never installed twice
|
||||
# --------------------------------------------------
|
||||
# WSL2 distros share one kernel and one network stack, so two dockerd instances
|
||||
# contend over docker0 and iptables and can disturb the daemon you depend on.
|
||||
# (That is why Docker Desktop runs one daemon in a dedicated distro and shares
|
||||
# its socket rather than installing one per distro.)
|
||||
#
|
||||
# REUSE_DOCKER=1 (default) borrow the host distro's daemon over /mnt/wsl.
|
||||
# Nothing is installed; nothing can conflict.
|
||||
# Requires `ctrl/dockerhost.sh share` once on the
|
||||
# distro that owns Docker.
|
||||
# REUSE_DOCKER=0 install a second daemon in the new distro. Only
|
||||
# if you specifically want to test a from-scratch
|
||||
# Docker install, and not on a machine you need.
|
||||
#
|
||||
# Borrowing is also the more honest test: rig never installs Docker anyway — it
|
||||
# is the documented prerequisite — so a clean box does not need its own to
|
||||
# exercise everything rig actually does.
|
||||
#
|
||||
# Usage: newbox.sh create | destroy [--purge] | status | shell
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
source ./lib/config.sh
|
||||
load_config
|
||||
|
||||
REPO="$(cd .. && pwd)"
|
||||
|
||||
# The distro is named after this environment, and that derived name is the ONLY
|
||||
# thing this script will ever destroy. See guard_name().
|
||||
BOX="${BOX:-${CLUSTER}box}"
|
||||
BOX_USER="${BOX_USER:-dev}"
|
||||
|
||||
# Borrow the host distro's Docker rather than installing a second daemon.
|
||||
REUSE_DOCKER="${REUSE_DOCKER:-1}"
|
||||
SHARED_SOCK=/mnt/wsl/shared-docker/docker.sock
|
||||
|
||||
WSL_EXE=/mnt/c/Windows/System32/wsl.exe
|
||||
|
||||
# ── host detection ─────────────────────────────────────────────────────────
|
||||
|
||||
require_wsl() {
|
||||
if ! grep -qi microsoft /proc/version 2>/dev/null; then
|
||||
cat >&2 <<'EOF'
|
||||
newbox is WSL-only for now.
|
||||
|
||||
On native Linux you do not need it: rig already isolates environments by
|
||||
directory (own cluster, context, images and port block), so a second copy in a
|
||||
second directory is the clean slate. To validate the installer itself against a
|
||||
bare system, run the wizard against a stock Debian container instead.
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -x "$WSL_EXE" ]; then
|
||||
echo "wsl.exe not found at $WSL_EXE" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
wsl_list() { "$WSL_EXE" -l -q 2>/dev/null | tr -d '\0\r'; }
|
||||
box_exists() { wsl_list | grep -qx "$BOX"; }
|
||||
|
||||
# `wsl --unregister` permanently deletes a distro's filesystem. The whole safety
|
||||
# story is this function: only the name derived from this directory can ever be
|
||||
# a target, so a typo or a stray argument cannot destroy the distro you work in.
|
||||
guard_name() {
|
||||
local derived="${CLUSTER}box"
|
||||
if [ "$BOX" != "$derived" ]; then
|
||||
echo "refusing: BOX='$BOX' is not the name derived from this directory ('$derived')." >&2
|
||||
echo "That guard exists because --unregister is irreversible." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$CLUSTER" ] || [ "$BOX" = "box" ]; then
|
||||
echo "refusing: empty environment name" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── create ─────────────────────────────────────────────────────────────────
|
||||
|
||||
rootfs_path() {
|
||||
local win_home; win_home=$(wslpath "$("$WSL_EXE" -d "$(wsl_list | head -1)" -e printf '%s' "$USERPROFILE" 2>/dev/null || true)" 2>/dev/null || true)
|
||||
# Simpler and reliable: use the current user's Windows home via /mnt/c.
|
||||
ls -d /mnt/c/Users/*/ 2>/dev/null | grep -viE '/(All Users|Default|Default User|Public)/$' | head -1
|
||||
}
|
||||
|
||||
build_rootfs() {
|
||||
local tar="$1"
|
||||
if [ -f "$tar" ]; then
|
||||
echo " rootfs cached: $(basename "$tar")"
|
||||
return
|
||||
fi
|
||||
echo " exporting a stock Debian rootfs (cached for next time)"
|
||||
local cid; cid=$(docker create debian:trixie-slim)
|
||||
docker export "$cid" > "$tar"
|
||||
docker rm -f "$cid" >/dev/null
|
||||
}
|
||||
|
||||
provision() {
|
||||
echo " provisioning (root)"
|
||||
local hosts_block
|
||||
hosts_block=$(CLUSTER="$CLUSTER" HTTP_PORT="$HTTP_PORT" \
|
||||
envsubst < ./hosts.tmpl 2>/dev/null || sed "s/\${CLUSTER}/$CLUSTER/g" ./hosts.tmpl)
|
||||
|
||||
# Piped as stdin rather than a second script file, the same shape as any
|
||||
# remote provisioning heredoc. Everything here is idempotent so a failed run
|
||||
# can simply be repeated.
|
||||
"$WSL_EXE" -d "$BOX" -u root -- bash -s <<PROVISION
|
||||
set -euo pipefail
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq ca-certificates curl gnupg sudo >/dev/null
|
||||
|
||||
if [ "$REUSE_DOCKER" = "1" ]; then
|
||||
# Borrow the host distro's daemon: CLI only, no dockerd, nothing to
|
||||
# conflict with. The GID must match the owner's or the shared socket is
|
||||
# unreadable here even though it is visible.
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
if [ ! -f /etc/apt/keyrings/docker.asc ]; then
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
fi
|
||||
echo "deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \$(. /etc/os-release && echo \$VERSION_CODENAME) stable" \
|
||||
> /etc/apt/sources.list.d/docker.list
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq docker-ce-cli >/dev/null
|
||||
|
||||
echo "export DOCKER_HOST=unix://$SHARED_SOCK" > /etc/profile.d/rig-docker-host.sh
|
||||
|
||||
if [ -f /mnt/wsl/shared-docker/OWNER ]; then
|
||||
gid=\$(awk '/docker gid:/ {print \$3}' /mnt/wsl/shared-docker/OWNER)
|
||||
if [ -n "\$gid" ]; then
|
||||
getent group docker >/dev/null && groupmod -g "\$gid" docker || groupadd -g "\$gid" docker
|
||||
fi
|
||||
fi
|
||||
else
|
||||
# A second daemon. Only when deliberately testing a from-scratch install.
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
if [ ! -f /etc/apt/keyrings/docker.asc ]; then
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
fi
|
||||
echo "deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \$(. /etc/os-release && echo \$VERSION_CODENAME) stable" \
|
||||
> /etc/apt/sources.list.d/docker.list
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq docker-ce docker-ce-cli containerd.io >/dev/null
|
||||
fi
|
||||
|
||||
id -u "$BOX_USER" >/dev/null 2>&1 || useradd -m -s /bin/bash "$BOX_USER"
|
||||
usermod -aG sudo,docker "$BOX_USER"
|
||||
echo "$BOX_USER ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/90-$BOX_USER
|
||||
chmod 0440 /etc/sudoers.d/90-$BOX_USER
|
||||
|
||||
# systemd is off by default in WSL, and Docker needs it. Takes effect on the
|
||||
# next start of this distro, which is why create() terminates it below.
|
||||
cat > /etc/wsl.conf <<WSLCONF
|
||||
[boot]
|
||||
systemd=true
|
||||
|
||||
[user]
|
||||
default=$BOX_USER
|
||||
WSLCONF
|
||||
|
||||
# The default inotify limits are low enough that file watching silently stops
|
||||
# working — no error, changes just stop being noticed. Fix it before it bites.
|
||||
cat > /etc/sysctl.d/99-rig.conf <<SYSCTL
|
||||
fs.inotify.max_user_watches=524288
|
||||
fs.inotify.max_user_instances=512
|
||||
SYSCTL
|
||||
|
||||
if ! grep -q 'rig environment' /etc/hosts 2>/dev/null; then
|
||||
{ echo ""; echo "# rig environment"; cat <<'HOSTS'
|
||||
$hosts_block
|
||||
HOSTS
|
||||
} >> /etc/hosts
|
||||
fi
|
||||
|
||||
touch /etc/rig-provisioned
|
||||
PROVISION
|
||||
}
|
||||
|
||||
create() {
|
||||
require_wsl
|
||||
guard_name
|
||||
|
||||
local winhome; winhome=$(rootfs_path)
|
||||
[ -n "$winhome" ] || { echo "could not locate the Windows user directory" >&2; exit 1; }
|
||||
local tar="${winhome}rig-rootfs.tar"
|
||||
local installdir="${winhome}WSL/${BOX}"
|
||||
|
||||
echo "creating '$BOX'"
|
||||
if [ "$REUSE_DOCKER" = "1" ]; then
|
||||
echo " docker: borrowing the host distro's daemon (nothing installed)"
|
||||
if [ ! -S "$SHARED_SOCK" ]; then
|
||||
echo
|
||||
echo " No shared socket yet. In the distro that owns Docker, run once:"
|
||||
echo " sudo bash ctrl/dockerhost.sh share"
|
||||
echo " That adds one systemd drop-in and nothing else; undo with 'unshare'."
|
||||
echo " Continuing — the box will be created, but Docker won't work in it"
|
||||
echo " until you do that."
|
||||
fi
|
||||
else
|
||||
echo
|
||||
echo " REUSE_DOCKER=0: installing a SECOND Docker daemon."
|
||||
echo " WSL distros share a network stack, so this can disturb Docker in"
|
||||
echo " the distro you work in. Ctrl-C now if that is a bad trade today."
|
||||
echo
|
||||
sleep 4
|
||||
fi
|
||||
echo
|
||||
|
||||
if box_exists; then
|
||||
echo " distro already registered"
|
||||
else
|
||||
build_rootfs "$tar"
|
||||
mkdir -p "$installdir"
|
||||
"$WSL_EXE" --import "$BOX" "$(wslpath -w "$installdir")" "$(wslpath -w "$tar")" --version 2
|
||||
fi
|
||||
|
||||
# Resumable: a partially-created box is finished rather than restarted.
|
||||
if "$WSL_EXE" -d "$BOX" -u root -- test -f /etc/rig-provisioned 2>/dev/null; then
|
||||
echo " already provisioned"
|
||||
else
|
||||
provision
|
||||
echo " restarting the distro so systemd and group membership apply"
|
||||
"$WSL_EXE" --terminate "$BOX" # ONLY this distro; never --shutdown
|
||||
fi
|
||||
|
||||
echo " copying rig in"
|
||||
tar c -C "$REPO" --exclude=def --exclude=.git --exclude=ctrl/.env . \
|
||||
| "$WSL_EXE" -d "$BOX" -u "$BOX_USER" -- bash -lc "mkdir -p ~/rig && tar x -C ~/rig"
|
||||
|
||||
echo
|
||||
echo " docker: $("$WSL_EXE" -d "$BOX" -u "$BOX_USER" -- bash -lc 'systemctl is-active docker 2>/dev/null || echo inactive')"
|
||||
echo
|
||||
echo "next:"
|
||||
echo " make newbox shell # a shell inside it"
|
||||
echo " then: cd ~/rig && make station && make deps && make cluster up"
|
||||
echo
|
||||
echo "For a browser on Windows to resolve the hostnames, paste this into"
|
||||
echo "C:\\Windows\\System32\\drivers\\etc\\hosts (it has no wildcard support):"
|
||||
CLUSTER="$CLUSTER" envsubst < ./hosts.tmpl 2>/dev/null | grep -v '^#' | grep -v '^$' | sed 's/^/ /'
|
||||
}
|
||||
|
||||
# ── the rest ───────────────────────────────────────────────────────────────
|
||||
|
||||
destroy() {
|
||||
require_wsl
|
||||
guard_name
|
||||
|
||||
if ! box_exists; then
|
||||
echo "no distro '$BOX' to remove"
|
||||
else
|
||||
echo "about to PERMANENTLY delete the distro '$BOX' and its filesystem."
|
||||
"$WSL_EXE" --terminate "$BOX" 2>/dev/null || true
|
||||
"$WSL_EXE" --unregister "$BOX"
|
||||
echo " unregistered"
|
||||
fi
|
||||
|
||||
local winhome; winhome=$(rootfs_path)
|
||||
rm -rf "${winhome}WSL/${BOX}" 2>/dev/null || true
|
||||
|
||||
if [ "${1:-}" = "--purge" ]; then
|
||||
rm -f "${winhome}rig-rootfs.tar"
|
||||
echo " cached rootfs removed"
|
||||
fi
|
||||
}
|
||||
|
||||
status() {
|
||||
require_wsl
|
||||
echo "environment $CLUSTER"
|
||||
echo "distro $BOX"
|
||||
if box_exists; then
|
||||
echo "registered yes"
|
||||
echo "provisioned $("$WSL_EXE" -d "$BOX" -u root -- test -f /etc/rig-provisioned 2>/dev/null && echo yes || echo no)"
|
||||
echo "docker $("$WSL_EXE" -d "$BOX" -u root -- bash -lc 'systemctl is-active docker 2>/dev/null' || echo unknown)"
|
||||
echo "rig copied $("$WSL_EXE" -d "$BOX" -u "$BOX_USER" -- bash -lc 'test -f ~/rig/Makefile && echo yes || echo no' 2>/dev/null)"
|
||||
else
|
||||
echo "registered no"
|
||||
fi
|
||||
echo
|
||||
echo "all distros (this one is never touched unless it is '$BOX'):"
|
||||
wsl_list | sed 's/^/ /'
|
||||
}
|
||||
|
||||
shell() {
|
||||
require_wsl
|
||||
box_exists || { echo "no distro '$BOX' — run 'make newbox' first" >&2; exit 1; }
|
||||
"$WSL_EXE" -d "$BOX" -u "$BOX_USER" --cd '~'
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
create) create ;;
|
||||
destroy) shift; destroy "${1:-}" ;;
|
||||
status) status ;;
|
||||
shell) shell ;;
|
||||
*) echo "usage: $0 [create|destroy [--purge]|status|shell]" >&2; exit 1 ;;
|
||||
esac
|
||||
@@ -1,25 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Give each environment its own block of host ports.
|
||||
#
|
||||
# New versions of a system mean new clusters on ONE machine, not new machines.
|
||||
# Cluster name, kubectl context, registry container and image tag already derive
|
||||
# from the directory name, so two copies never collide there — but host ports are
|
||||
# a single shared namespace and would.
|
||||
#
|
||||
# The block is derived from the directory name: stateless, stable, and requiring
|
||||
# no coordination between copies that know nothing about each other.
|
||||
#
|
||||
# base = 20000 + (hash(slug) % 200) * 10
|
||||
# +0 HTTP +1 HTTPS +2 TILT +3 REGISTRY (+4..9 reserved)
|
||||
#
|
||||
# 20000+ deliberately avoids the ports something is already likely to hold: 80,
|
||||
# 443, 3000, 5432, 8000, 8080.
|
||||
#
|
||||
# Derivation is a default, not a decision. On first use the resolved block is
|
||||
# written into ctrl/.env, so it becomes pinned, visible and editable rather than
|
||||
# a number that appears from nowhere. Anything already in ctrl/.env wins.
|
||||
#
|
||||
# Usage: ports.sh show | derive | persist
|
||||
# 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")"
|
||||
|
||||
@@ -38,6 +21,22 @@ derive() {
|
||||
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"
|
||||
@@ -64,6 +63,13 @@ _row() {
|
||||
# rewritten — an override stays an override.
|
||||
persist() {
|
||||
derive
|
||||
# ctrl/.env belongs to this rig, not to an overlay: a pin written now would
|
||||
# follow every overlay this rig later runs, and two of them would collide.
|
||||
if [ -n "${OVERLAY:-}" ]; then
|
||||
echo "not pinning: OVERLAY is set, and ctrl/.env would carry this block to every overlay" >&2
|
||||
echo " its ports stay derived from its folder name ($CLUSTER): $DERIVED_HTTP-$DERIVED_REGISTRY" >&2
|
||||
exit 1
|
||||
fi
|
||||
[ -f ./.env ] || cp ./.env.example ./.env
|
||||
|
||||
local wrote=0 key val
|
||||
@@ -98,6 +104,7 @@ persist() {
|
||||
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|derive|persist]" >&2; exit 1 ;;
|
||||
*) echo "usage: $0 [show|active|derive|persist]" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
@@ -1,28 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Registry plumbing. THIS is the seam — not a tool.
|
||||
#
|
||||
# Four modes, selected by REGISTRY_MODE in the active profile:
|
||||
#
|
||||
# none Tilt builds straight into the node. No registry at all — and so no
|
||||
# guard against an outward push: an unqualified image name means
|
||||
# docker.io/library/<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. What a locked-down client actually looks like:
|
||||
# images originate from corp, you don't hammer it, and you keep
|
||||
# working when the VPN drops.
|
||||
# remote no local container; pull straight from the corporate registry using
|
||||
# an imagePullSecret.
|
||||
#
|
||||
# Deliberately a script rather than a tool. ctlptl collapses the `local` wiring
|
||||
# into one line, but its Registry spec only accepts name/port/image/listenAddress
|
||||
# — there is no way to set REGISTRY_PROXY_REMOTEURL, so it cannot express
|
||||
# `mirror` at all. Keeping the seam here is what keeps the corporate registry
|
||||
# swappable.
|
||||
#
|
||||
# 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")"
|
||||
|
||||
@@ -34,17 +14,7 @@ REG_PORT="${REGISTRY_PORT:-5005}"
|
||||
K="kubectl --context ${KUBECONTEXT}"
|
||||
|
||||
# ── CA trust ───────────────────────────────────────────────────────────────
|
||||
# A corporate registry is almost always fronted by an internal CA, and trust has
|
||||
# to reach three separate places. Nothing does this for you, and the symptom when
|
||||
# it's missing is an opaque:
|
||||
# x509: certificate signed by unknown authority
|
||||
#
|
||||
# 1. the host docker daemon — /etc/docker/certs.d/<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
|
||||
#
|
||||
# We handle (2) here because it's ours to handle. (1) is reported by station.sh
|
||||
# since it needs root. (3) belongs to the workload.
|
||||
# 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
|
||||
|
||||
|
||||
415
rig/ctrl/selftest.sh
Executable file
415
rig/ctrl/selftest.sh
Executable file
@@ -0,0 +1,415 @@
|
||||
#!/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}')"
|
||||
mkdir -p "$OVR/local/renamed"; printf 'CLUSTER=pinned\n' > "$OVR/local/renamed/rig.env"
|
||||
check "rig.env's CLUSTER names it, whatever the folder" "pinned kind-pinned $(derive_port_base pinned)" \
|
||||
"$(in_ov env OVERLAY=local/renamed bash ports.sh active | awk '{print $1, $2, $3}')"
|
||||
check "the caller's CLUSTER beats rig.env's" "second kind-second" \
|
||||
"$(in_ov env OVERLAY=local/renamed CLUSTER=second bash ports.sh active | awk '{print $1, $2}')"
|
||||
check "make docs serves DOCS_DIR, the folder it runs from" "no index.html in $OVR/local/renamed" \
|
||||
"$(in_ov env DOCS_DIR="$OVR/local/renamed" bash docs.sh serve 2>&1 >/dev/null | head -1)"
|
||||
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/*.sh | xargs -n1 basename | sed 's/\.sh$//' | sort | xargs)" # the scripts; their manifests sit beside them
|
||||
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"
|
||||
# An addon's manifest may only name what the config or its own script sets, so a typo
|
||||
# fails here rather than as an empty value on a cluster.
|
||||
bad=""
|
||||
for y in addons/*.yaml ../examples/*/addons/*.yaml; do
|
||||
[ -e "$y" ] || continue
|
||||
for n in $(grep -oE '\$\{[A-Za-z_][A-Za-z0-9_]*\}' "$y" | tr -d '${}' | sort -u); do
|
||||
grep -qE "(^|[[:space:]])$n=" "${y%.yaml}.sh" versions.env ../examples/*/rig.env 2>/dev/null || bad+="$y:$n "
|
||||
done
|
||||
done
|
||||
check "every addon manifest names only what is set" "" "$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"
|
||||
@@ -1,249 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Prepare a machine to run rig, and say plainly what worked, what was already
|
||||
# done, and what is left for a human.
|
||||
#
|
||||
# This is the grouped entry point: `make setup`. Every step is idempotent and
|
||||
# independently checked, so running it twice is safe and running it on a
|
||||
# half-configured machine finishes the job rather than starting over.
|
||||
#
|
||||
# It deliberately does NOT abort on the first failure. A setup script that dies
|
||||
# at step 2 hides the fact that steps 4 and 5 were also going to fail — and on
|
||||
# an unfamiliar machine, the full picture is the whole point. Failures are
|
||||
# collected and reported together, and the exit code reflects the worst outcome.
|
||||
#
|
||||
# The same script runs inside a fresh throwaway distro (newbox), so the
|
||||
# provisioning path and the everyday path cannot drift apart.
|
||||
#
|
||||
# Usage:
|
||||
# setup.sh # host checks + the dev toolchain
|
||||
# setup.sh core # kubectl and jq only — no cluster tooling
|
||||
# setup.sh --share-docker # ...and offer this distro's Docker to others
|
||||
# setup.sh --cluster # ...and bring the cluster up
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
source ./lib/config.sh
|
||||
load_config
|
||||
|
||||
WITH_SHARE=0
|
||||
WITH_CLUSTER=0
|
||||
# Cluster tooling is not wanted everywhere: a managed or corporate-issued
|
||||
# machine may legitimately want kubectl and nothing that builds clusters.
|
||||
TIER=dev
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
core|dev) TIER="$a" ;;
|
||||
--share-docker) WITH_SHARE=1 ;;
|
||||
--cluster) WITH_CLUSTER=1 ;;
|
||||
*) echo "unknown option: $a" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$TIER" = "core" ] && [ "$WITH_CLUSTER" -eq 1 ]; then
|
||||
echo "core tier installs no cluster tooling, so --cluster cannot work" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── step framework ─────────────────────────────────────────────────────────
|
||||
# Statuses are deliberately distinct: "already" and "done" both mean success but
|
||||
# tell you very different things about the machine you are on.
|
||||
STEP_NAMES=()
|
||||
STEP_STATUS=()
|
||||
STEP_NOTE=()
|
||||
WORST=0
|
||||
|
||||
record() {
|
||||
STEP_NAMES+=("$1"); STEP_STATUS+=("$2"); STEP_NOTE+=("${3:-}")
|
||||
# Only a genuine failure is a non-zero exit. "manual" means the machine is
|
||||
# fine and you have something to do — reporting that as an error makes the
|
||||
# whole run look broken and trains people to ignore the output.
|
||||
[ "$2" = "fail" ] && WORST=1 || true
|
||||
local mark
|
||||
case "$2" in
|
||||
already) mark=" ok " ;;
|
||||
done) mark=" done " ;;
|
||||
skip) mark=" skip " ;;
|
||||
manual) mark="MANUAL" ;;
|
||||
fail) mark=" FAIL " ;;
|
||||
esac
|
||||
printf "[%s] %-22s %s\n" "$mark" "$1" "${3:-}"
|
||||
}
|
||||
|
||||
# ── steps ──────────────────────────────────────────────────────────────────
|
||||
|
||||
step_host() {
|
||||
local out
|
||||
if ! out=$(bash ./wizard.sh detect 2>&1); then
|
||||
record host fail "detection failed"
|
||||
return
|
||||
fi
|
||||
# Anything the wizard flagged with '!' needs a human; surface the count here
|
||||
# and the detail below rather than burying it.
|
||||
local warns; warns=$(echo "$out" | grep -c '^\s*!' || true)
|
||||
HOST_DETAIL="$out"
|
||||
if [ "$warns" -gt 0 ]; then
|
||||
record host manual "$warns item(s) need attention — see below"
|
||||
else
|
||||
record host already "no problems detected"
|
||||
fi
|
||||
}
|
||||
|
||||
step_toolchain() {
|
||||
local want="kubectl jq"
|
||||
[ "$TIER" = "dev" ] && want="$want kind tilt"
|
||||
|
||||
local missing=""
|
||||
for b in $want; do
|
||||
command -v "$b" >/dev/null 2>&1 || missing="$missing $b"
|
||||
done
|
||||
|
||||
if [ -z "$missing" ]; then
|
||||
record toolchain already "$TIER: $want"
|
||||
return
|
||||
fi
|
||||
|
||||
if bash ./wizard.sh install "$TIER" >/tmp/rig-deps.$$ 2>&1; then
|
||||
local still=""
|
||||
for b in $want; do
|
||||
[ -x "${OUT_BIN:-$HOME/.local/bin}/$b" ] || still="$still $b"
|
||||
done
|
||||
if [ -n "$still" ]; then
|
||||
record toolchain fail "still missing:$still (see /tmp/rig-deps.$$)"
|
||||
else
|
||||
record toolchain done "$TIER, installed:$missing"
|
||||
rm -f "/tmp/rig-deps.$$"
|
||||
fi
|
||||
else
|
||||
record toolchain fail "install failed — see /tmp/rig-deps.$$"
|
||||
fi
|
||||
}
|
||||
|
||||
step_path() {
|
||||
local bin="${OUT_BIN:-$HOME/.local/bin}"
|
||||
case ":$PATH:" in
|
||||
*":$bin:"*) ;;
|
||||
*) record path manual "add to ~/.bashrc: export PATH=\"$bin:\$PATH\""; return ;;
|
||||
esac
|
||||
if grep -qs "$bin" "$HOME/.bashrc" "$HOME/.profile" 2>/dev/null; then
|
||||
record path already "$bin on PATH and persisted"
|
||||
else
|
||||
record path manual "on PATH now, but not persisted in ~/.bashrc"
|
||||
fi
|
||||
}
|
||||
|
||||
step_docker() {
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
record docker fail "no docker cli — this is the one prerequisite rig cannot install"
|
||||
return
|
||||
fi
|
||||
if docker info >/dev/null 2>&1; then
|
||||
record docker already "$(docker version --format '{{.Server.Version}}' 2>/dev/null)"
|
||||
else
|
||||
record docker fail "daemon unreachable (in the docker group? logged out and back in?)"
|
||||
fi
|
||||
}
|
||||
|
||||
step_share_docker() {
|
||||
if [ "$WITH_SHARE" -ne 1 ]; then
|
||||
record docker-share skip "not requested (--share-docker)"
|
||||
return
|
||||
fi
|
||||
if ! grep -qi microsoft /proc/version 2>/dev/null; then
|
||||
record docker-share skip "not WSL — sharing only applies between WSL distros"
|
||||
return
|
||||
fi
|
||||
if [ -f /etc/systemd/system/docker.service.d/10-rig-shared-socket.conf ]; then
|
||||
record docker-share already "this distro is offering its Docker to others"
|
||||
return
|
||||
fi
|
||||
# Needs root, and asking mid-script is worse than telling the user the
|
||||
# single command to run.
|
||||
if [ "$(id -u)" -ne 0 ] && ! sudo -n true 2>/dev/null; then
|
||||
record docker-share manual "run: sudo bash ctrl/dockerhost.sh share"
|
||||
return
|
||||
fi
|
||||
if sudo bash ./dockerhost.sh share >/tmp/rig-share.$$ 2>&1; then
|
||||
record docker-share done "this distro now owns the shared Docker"
|
||||
rm -f "/tmp/rig-share.$$"
|
||||
else
|
||||
record docker-share fail "see /tmp/rig-share.$$"
|
||||
fi
|
||||
}
|
||||
|
||||
step_ports() {
|
||||
local busy=""
|
||||
for entry in "HTTP:$HTTP_PORT" "HTTPS:$HTTPS_PORT" "TILT:$TILT_PORT" "REGISTRY:$REGISTRY_PORT"; do
|
||||
local p="${entry#*:}"
|
||||
if command -v ss >/dev/null 2>&1 && ss -ltn "sport = :$p" 2>/dev/null | grep -q LISTEN; then
|
||||
busy="$busy ${entry%%:*}($p)"
|
||||
fi
|
||||
done
|
||||
if [ -n "$busy" ]; then
|
||||
record ports fail "in use:$busy — override in ctrl/.env or rename the directory"
|
||||
else
|
||||
record ports already "$HTTP_PORT-$REGISTRY_PORT free"
|
||||
fi
|
||||
}
|
||||
|
||||
step_cluster() {
|
||||
if [ "$TIER" = "core" ]; then
|
||||
record cluster skip "core tier — no cluster tooling on this machine"
|
||||
return
|
||||
fi
|
||||
if [ "$WITH_CLUSTER" -ne 1 ]; then
|
||||
record cluster skip "not requested (--cluster)"
|
||||
return
|
||||
fi
|
||||
if kind get clusters 2>/dev/null | grep -qx "$CLUSTER"; then
|
||||
record cluster already "'$CLUSTER' exists"
|
||||
return
|
||||
fi
|
||||
if bash ./cluster.sh up >/tmp/rig-cluster.$$ 2>&1; then
|
||||
record cluster done "'$CLUSTER' created"
|
||||
rm -f "/tmp/rig-cluster.$$"
|
||||
else
|
||||
record cluster fail "see /tmp/rig-cluster.$$"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── run ────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "setting up '$CLUSTER'"
|
||||
echo
|
||||
HOST_DETAIL=""
|
||||
step_host
|
||||
step_toolchain
|
||||
step_path
|
||||
step_docker
|
||||
step_share_docker
|
||||
step_ports
|
||||
step_cluster
|
||||
|
||||
echo
|
||||
if [ -n "$HOST_DETAIL" ]; then
|
||||
echo "host detail"
|
||||
echo "$HOST_DETAIL" | sed 's/^/ /'
|
||||
echo
|
||||
fi
|
||||
|
||||
# Repeat only what still needs action, so the tail of the output is a to-do list
|
||||
# rather than a transcript.
|
||||
outstanding=0
|
||||
for i in "${!STEP_NAMES[@]}"; do
|
||||
case "${STEP_STATUS[$i]}" in
|
||||
fail|manual)
|
||||
[ "$outstanding" -eq 0 ] && echo "outstanding:"
|
||||
outstanding=1
|
||||
printf " %-8s %-16s %s\n" "${STEP_STATUS[$i]}" "${STEP_NAMES[$i]}" "${STEP_NOTE[$i]}"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$outstanding" -eq 0 ]; then
|
||||
echo "ready. next: make cluster up && make docs"
|
||||
else
|
||||
echo
|
||||
echo "(nothing was aborted — every step ran so the list above is complete)"
|
||||
fi
|
||||
|
||||
exit "$WORST"
|
||||
350
rig/ctrl/standalone.sh
Normal file
350
rig/ctrl/standalone.sh
Normal 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
|
||||
@@ -1,106 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Station check: is this workstation ready to run rig?
|
||||
#
|
||||
# Reports and instructs; never silently fixes anything. Everything it finds is
|
||||
# either already fine, or something a human has to decide on.
|
||||
#
|
||||
# Runs the wizard's host detection in a container when Docker is the only thing
|
||||
# installed, or directly when the toolchain is already present. Then adds the
|
||||
# checks that need this repo's config: profile sanity, CA trust, port clashes.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
WIZARD_IMAGE="${WIZARD_IMAGE:-$(basename "$(cd .. && pwd)")-wizard}"
|
||||
|
||||
# Host detection. Prefer running it bare — it needs no dependencies beyond
|
||||
# coreutils — and fall back to the container only if this shell can't.
|
||||
bash ./wizard.sh detect
|
||||
|
||||
# ── repo-level checks ──────────────────────────────────────────────────────
|
||||
|
||||
source ./lib/config.sh
|
||||
load_config
|
||||
|
||||
echo
|
||||
echo "config"
|
||||
echo " profile ${PROFILE_NAME} (nodes=${NODES} audit=${AUDIT})"
|
||||
echo " cluster ${CLUSTER} (context ${KUBECONTEXT})"
|
||||
echo " registry ${REGISTRY_MODE}"
|
||||
echo " ingress ${INGRESS_MODE}"
|
||||
|
||||
if [ ! -f ./.env ]; then
|
||||
echo " ! ctrl/.env missing — copy it: cp ctrl/.env.example ctrl/.env"
|
||||
fi
|
||||
|
||||
# A 3-node profile on a box that's already full is the most common first
|
||||
# failure, and it presents as pods stuck Pending rather than anything obvious.
|
||||
avail=$(awk '/^MemAvailable:/{printf "%d", $2/1024/1024}' /proc/meminfo)
|
||||
need=$((NODES * 2))
|
||||
if [ "$avail" -lt "$need" ]; then
|
||||
echo " ! profile '${PROFILE_NAME}' wants ~${need} GB, ${avail} GB available"
|
||||
echo " 'make cluster list' shows what else is running; 'make cluster free' stops it"
|
||||
fi
|
||||
|
||||
# The CA reaches three places and only one of them is ours. Report the other two.
|
||||
if [ -n "${REGISTRY_CA_FILE:-}" ]; then
|
||||
echo
|
||||
echo "registry CA"
|
||||
if [ ! -r "$REGISTRY_CA_FILE" ]; then
|
||||
echo " ! REGISTRY_CA_FILE not readable: $REGISTRY_CA_FILE"
|
||||
else
|
||||
echo " file $REGISTRY_CA_FILE"
|
||||
host="${REGISTRY_REMOTE_URL#*://}"; host="${host%%/*}"
|
||||
if [ -n "$host" ] && [ ! -f "/etc/docker/certs.d/${host}/ca.crt" ]; then
|
||||
echo " ! the HOST docker daemon does not trust it yet:"
|
||||
echo " sudo mkdir -p /etc/docker/certs.d/${host}"
|
||||
echo " sudo cp ${REGISTRY_CA_FILE} /etc/docker/certs.d/${host}/ca.crt"
|
||||
echo " (kind nodes are handled by registry.sh; in-cluster clients are the workload's job)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Host ports this environment will try to bind. Checked before cluster creation
|
||||
# because docker reports a clash halfway through, as an opaque
|
||||
# "failed to bind host port ...: address already in use".
|
||||
echo
|
||||
echo "ports (block derived from the directory name — see 'make ports')"
|
||||
|
||||
port_busy() {
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -ltn "sport = :$1" 2>/dev/null | grep -q LISTEN && return 0 || return 1
|
||||
fi
|
||||
# iproute2 is absent from a minimal Debian, so fall back to procfs rather
|
||||
# than silently reporting everything as free.
|
||||
local hex; hex=$(printf ':%04X' "$1")
|
||||
grep -qi "^ *[0-9]*: [0-9A-F]*$hex " /proc/net/tcp /proc/net/tcp6 2>/dev/null
|
||||
}
|
||||
|
||||
# A port held by THIS environment's own cluster is not a clash — it is the thing
|
||||
# working. Reporting it as a problem every time the cluster is up would train
|
||||
# people to ignore this section, which is the opposite of the point.
|
||||
# Extract with a second grep rather than `tr -d ':->'`: in tr, ':->' is the
|
||||
# character RANGE ':' to '>', which does not contain '-', so the trailing dash
|
||||
# survives and nothing ever matches.
|
||||
ours=$(docker ps --filter "label=io.x-k8s.kind.cluster=${CLUSTER}" \
|
||||
--format '{{.Ports}}' 2>/dev/null | tr ',' '\n' \
|
||||
| grep -oE ':[0-9]+->' | grep -oE '[0-9]+' || true)
|
||||
|
||||
clash=0
|
||||
for entry in "HTTP:${HTTP_PORT}" "HTTPS:${HTTPS_PORT}" \
|
||||
"TILT:${TILT_PORT}" "REGISTRY:${REGISTRY_PORT}"; do
|
||||
name="${entry%%:*}"; p="${entry#*:}"
|
||||
[ -n "$p" ] || continue
|
||||
if ! port_busy "$p"; then
|
||||
printf " %-9s %-6s free\n" "$name" "$p"
|
||||
elif echo "$ours" | grep -qx "$p"; then
|
||||
printf " %-9s %-6s in use by this environment's cluster\n" "$name" "$p"
|
||||
else
|
||||
printf " ! %-9s %-6s IN USE by something else\n" "$name" "$p"
|
||||
clash=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$clash" -eq 1 ]; then
|
||||
echo " override the clashing one in ctrl/.env, e.g. HTTP_PORT=21080"
|
||||
echo " (or rename this directory — the whole block follows the name)"
|
||||
fi
|
||||
@@ -1,13 +1,7 @@
|
||||
# Pinned toolchain — the single manifest the wizard installs from.
|
||||
# Every entry is a single binary; none of them needs an apt repo.
|
||||
# kubectl fully static
|
||||
# kind libc only
|
||||
# tilt libc + libstdc++ + libgcc (present in base Debian)
|
||||
# jq upstream static build (Debian's is linked against libjq/libonig)
|
||||
#
|
||||
# Checksums are the upstream-published SHA256 of the linux/amd64 artifact.
|
||||
# To bump: change the version, then re-run `bash ctrl/versions-refresh.sh` and
|
||||
# commit the result — never hand-edit a checksum.
|
||||
# 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
|
||||
@@ -21,34 +15,43 @@ 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
|
||||
|
||||
# Node images shipped with KIND_VERSION above, pinned by digest so a kind upgrade
|
||||
# can never silently move the k8s version. Profiles select one via K8S_VERSION.
|
||||
# Older entries are kept deliberately: running a trailing-edge control plane is
|
||||
# part of simulating a legacy estate.
|
||||
# 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 (registry, mocks). Pinned by tag; the registry mode
|
||||
# decides where they are pulled FROM.
|
||||
# Images pulled at runtime. Pinned by tag; the registry mode decides where they are pulled FROM.
|
||||
REGISTRY_IMAGE=registry:2
|
||||
STUB_IMAGE=python:3.12-slim
|
||||
|
||||
# Addons, installed by ctrl/addons/<name>.sh when listed in a profile's ADDONS.
|
||||
# rig's own addons (ctrl/addons/<name>.sh), installed when ADDONS names them.
|
||||
CERT_MANAGER_VERSION=v1.21.1
|
||||
METRICS_SERVER_VERSION=v0.9.0
|
||||
METALLB_VERSION=v0.16.0
|
||||
|
||||
# Dependency containers. These mirror soleprint's cabinets
|
||||
# (soleprint/station/cabinets/), so a room that declares postgres gets the same
|
||||
# thing whether it runs on compose or in the cluster. Pinned by tag rather than
|
||||
# digest because they are ordinary upstream images with no supply chain claim
|
||||
# attached — bump freely, and preload them for the offline profile.
|
||||
POSTGRES_IMAGE=postgres:16-alpine
|
||||
REDIS_IMAGE=redis:7-alpine
|
||||
AIRFLOW_IMAGE=apache/airflow:2.10.4
|
||||
# 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
|
||||
|
||||
@@ -1,381 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# The installation wizard: detect the host, install a pinned toolchain onto it,
|
||||
# then report what it could not do. It never runs the cluster and never mutates
|
||||
# the host outside the directories mounted into it.
|
||||
#
|
||||
# Usage (normally via `make station` / `make deps`, or directly):
|
||||
# wizard.sh detect # report host facts only, change nothing
|
||||
# wizard.sh fetch [core|dev] [--to DIR] # download + verify into DIR
|
||||
# wizard.sh install [core|dev] # detect, fetch, install, report
|
||||
#
|
||||
# Tiers: 'core' is kubectl + jq (talk to a cluster); 'dev' adds kind and tilt
|
||||
# Default is dev.
|
||||
#
|
||||
# Runs both inside the wizard container and bare on a host. Inside the
|
||||
# container, host files are read through $HOST_ROOT (mount / as :ro); bare, it
|
||||
# falls back to /.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Keep the caller's cwd so a relative --to resolves where the user expects,
|
||||
# not against ctrl/ once we've moved.
|
||||
INVOKED_FROM="$PWD"
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
source ./versions.env
|
||||
|
||||
# Resolve a possibly-relative path against the caller's original directory.
|
||||
abspath() {
|
||||
case "$1" in
|
||||
/*) echo "$1" ;;
|
||||
*) echo "$INVOKED_FROM/$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
OUT_BIN="${OUT_BIN:-$HOME/.local/bin}"
|
||||
HOST_ROOT="${HOST_ROOT:-/}"
|
||||
DEPS_SOURCE="${DEPS_SOURCE:-upstream}"
|
||||
DEPS_ARTIFACTORY_URL="${DEPS_ARTIFACTORY_URL:-}"
|
||||
BAKED_BIN="${BAKED_BIN:-/opt/rig/bin}"
|
||||
|
||||
# Collected by detect(), printed by report_manual() at the very end.
|
||||
MANUAL=()
|
||||
|
||||
# Host FILES (/etc/..., /mnt/c/...) must be read through the mount. Kernel-level
|
||||
# facts (kernel version, meminfo, inotify) are shared with the container, so the
|
||||
# container's own view is already the host's.
|
||||
host_file() {
|
||||
local p="${1#/}"
|
||||
if [ "$HOST_ROOT" != "/" ] && [ -e "$HOST_ROOT/$p" ]; then
|
||||
echo "$HOST_ROOT/$p"
|
||||
else
|
||||
echo "/$p"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── detect ─────────────────────────────────────────────────────────────────
|
||||
|
||||
is_wsl() { grep -qi microsoft /proc/version 2>/dev/null; }
|
||||
|
||||
detect() {
|
||||
echo "host"
|
||||
echo " kernel $(uname -r)"
|
||||
|
||||
local osr; osr=$(host_file /etc/os-release)
|
||||
[ -r "$osr" ] && echo " distro $(sed -n 's/^PRETTY_NAME="\(.*\)"/\1/p' "$osr")"
|
||||
|
||||
local total_kb avail_kb
|
||||
total_kb=$(awk '/^MemTotal:/{print $2}' /proc/meminfo)
|
||||
avail_kb=$(awk '/^MemAvailable:/{print $2}' /proc/meminfo)
|
||||
printf " memory %d GB total, %d GB available\n" \
|
||||
$((total_kb / 1024 / 1024)) $((avail_kb / 1024 / 1024))
|
||||
|
||||
if [ $((avail_kb / 1024 / 1024)) -lt 4 ]; then
|
||||
echo " ! under 4 GB available — a multi-node profile will struggle."
|
||||
echo " 'make cluster list' shows the others; 'make cluster free' stops them."
|
||||
fi
|
||||
|
||||
detect_wsl
|
||||
detect_docker
|
||||
detect_inotify
|
||||
}
|
||||
|
||||
detect_wsl() {
|
||||
if ! is_wsl; then
|
||||
echo " platform native linux"
|
||||
return
|
||||
fi
|
||||
|
||||
echo " platform WSL"
|
||||
|
||||
# systemd is off by default in WSL, and the ingress/DNS paths that use a
|
||||
# host service need it. Enabling it requires a Windows-side restart, which
|
||||
# cannot be issued from inside the distro.
|
||||
local wc; wc=$(host_file /etc/wsl.conf)
|
||||
if [ -r "$wc" ] && grep -qE '^\s*systemd\s*=\s*true' "$wc"; then
|
||||
echo " systemd enabled in wsl.conf"
|
||||
else
|
||||
echo " ! systemd not enabled in /etc/wsl.conf"
|
||||
MANUAL+=("Enable systemd — add to /etc/wsl.conf:
|
||||
[boot]
|
||||
systemd=true
|
||||
then from a WINDOWS terminal (not this shell): wsl --shutdown")
|
||||
fi
|
||||
|
||||
# WSL regenerates /etc/resolv.conf on every boot, which silently reverts any
|
||||
# local DNS setup.
|
||||
if [ -r "$wc" ] && grep -qE '^\s*generateResolvConf\s*=\s*false' "$wc"; then
|
||||
echo " resolv.conf pinned (generateResolvConf=false)"
|
||||
else
|
||||
echo " - resolv.conf is WSL-generated; DNS_MODE=dnsmasq would be reverted on reboot"
|
||||
fi
|
||||
|
||||
local wcfg
|
||||
wcfg=$(ls "$HOST_ROOT"/mnt/c/Users/*/.wslconfig 2>/dev/null | head -1 || true)
|
||||
if [ -n "$wcfg" ] && grep -qE '^\s*memory\s*=' "$wcfg"; then
|
||||
echo " wslconfig memory set: $(grep -E '^\s*memory\s*=' "$wcfg" | tr -d ' ')"
|
||||
else
|
||||
MANUAL+=("Cap/raise the WSL VM memory — in %USERPROFILE%\\.wslconfig on Windows:
|
||||
[wsl2]
|
||||
memory=8GB
|
||||
then from a WINDOWS terminal: wsl --shutdown")
|
||||
fi
|
||||
}
|
||||
|
||||
detect_docker() {
|
||||
# Reachability of the daemon is the real question, and the CLI is only how
|
||||
# we ask it. Note that when this runs inside the wizard container, Docker
|
||||
# necessarily exists on the host — otherwise nothing would be executing —
|
||||
# so a missing CLI in here is a wizard packaging bug, not a host problem.
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
echo " docker socket present (no cli in this context)"
|
||||
else
|
||||
echo " ! docker not found and no socket at /var/run/docker.sock"
|
||||
MANUAL+=("Install Docker — the one true prerequisite:
|
||||
sudo apt-get install -y docker.io && sudo usermod -aG docker \"\$USER\"
|
||||
then log out and back in.")
|
||||
fi
|
||||
return
|
||||
fi
|
||||
if docker info >/dev/null 2>&1; then
|
||||
echo " docker $(docker version --format '{{.Server.Version}}' 2>/dev/null)"
|
||||
local n
|
||||
n=$(docker ps --filter "label=io.x-k8s.kind.cluster" --format '{{.Names}}' 2>/dev/null | wc -l)
|
||||
# Must be an `if`, not `[ ] && echo`: as the last statement in this
|
||||
# function the latter returns 1 when the count is zero, and `set -e`
|
||||
# then kills the caller. That is the fresh-machine case — no clusters
|
||||
# yet — so the bug only ever shows up where it does most harm.
|
||||
if [ "$n" -gt 0 ]; then
|
||||
echo " - $n kind node container(s) already running; see 'make cluster list'"
|
||||
fi
|
||||
else
|
||||
echo " ! docker cli present but the daemon is unreachable"
|
||||
MANUAL+=("Start Docker, or add yourself to the docker group:
|
||||
sudo usermod -aG docker \"\$USER\" # then log out and back in")
|
||||
fi
|
||||
}
|
||||
|
||||
# kind and Tilt both watch large trees. WSL ships defaults (8192/128) far too low,
|
||||
# and the failure mode is silent: Tilt simply stops noticing file changes.
|
||||
detect_inotify() {
|
||||
local w i
|
||||
w=$(cat /proc/sys/fs/inotify/max_user_watches 2>/dev/null || echo 0)
|
||||
i=$(cat /proc/sys/fs/inotify/max_user_instances 2>/dev/null || echo 0)
|
||||
echo " inotify watches=$w instances=$i"
|
||||
|
||||
if [ "$w" -lt 524288 ] || [ "$i" -lt 512 ]; then
|
||||
echo " ! inotify limits are low — Tilt will silently stop noticing file changes"
|
||||
MANUAL+=("Raise inotify limits (needs root on the host):
|
||||
echo -e 'fs.inotify.max_user_watches=524288\\nfs.inotify.max_user_instances=512' \\
|
||||
| sudo tee /etc/sysctl.d/99-rig.conf
|
||||
sudo sysctl --system")
|
||||
fi
|
||||
}
|
||||
|
||||
# ── fetch ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# Resolve where a given artifact comes from, honouring DEPS_SOURCE.
|
||||
resolve_url() {
|
||||
local upstream="$1"
|
||||
case "$DEPS_SOURCE" in
|
||||
upstream) echo "$upstream" ;;
|
||||
artifactory)
|
||||
if [ -z "$DEPS_ARTIFACTORY_URL" ]; then
|
||||
echo "DEPS_SOURCE=artifactory but DEPS_ARTIFACTORY_URL is empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "${DEPS_ARTIFACTORY_URL%/}/$(basename "$upstream")"
|
||||
;;
|
||||
*) echo "unsupported DEPS_SOURCE '$DEPS_SOURCE' for a download" >&2; exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
verify() {
|
||||
local file="$1" want="$2" name="$3" got
|
||||
got=$(sha256sum "$file" | awk '{print $1}')
|
||||
if [ "$got" != "$want" ]; then
|
||||
echo "checksum mismatch for $name" >&2
|
||||
echo " expected $want" >&2
|
||||
echo " got $got" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# fetch_bin <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"
|
||||
curl -fsSL --retry 3 -o "$tmp" "$(resolve_url "$url")"
|
||||
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"
|
||||
curl -fsSL --retry 3 -o "$tmp" "$(resolve_url "$url")"
|
||||
verify "$tmp" "$sha" "$name"
|
||||
# --no-same-owner: extracting as root would otherwise restore the uid/gid
|
||||
# baked into the archive (some ship as uid 1001), leaving a binary the host
|
||||
# user does not own.
|
||||
tar -xzf "$tmp" -C "$dest" --strip-components="$strip" --no-same-owner "$inner"
|
||||
rm -f "$tmp"
|
||||
chmod +x "$dest/$name"
|
||||
}
|
||||
|
||||
# The wizard runs as root so it can reach the docker socket, which means
|
||||
# everything it writes into a mounted volume lands root-owned and unusable from
|
||||
# the host. Hand it back to whoever owns the mount point (the host user created
|
||||
# that directory before mounting it).
|
||||
fix_ownership() {
|
||||
local dir="$1"
|
||||
[ -d "$dir" ] || return 0
|
||||
local owner="${HOST_UID:-}:${HOST_GID:-}"
|
||||
if [ "$owner" = ":" ]; then
|
||||
owner=$(stat -c '%u:%g' "$dir")
|
||||
fi
|
||||
[ "$owner" = "0:0" ] && return 0
|
||||
chown -R "$owner" "$dir" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Two tiers, because not every machine should get cluster tooling.
|
||||
#
|
||||
# core kubectl, jq — talk to a cluster someone else runs. Nothing that
|
||||
# creates one. Appropriate on a managed or corporate-issued machine
|
||||
# where development tools are not wanted by default.
|
||||
# dev core plus kind and tilt — build clusters and hot-reload into them.
|
||||
#
|
||||
# The split exists because "install the toolchain" is not one decision: on a
|
||||
# managed workspace the right answer is kubectl and nothing else.
|
||||
CORE_TOOLS="kubectl jq"
|
||||
# No helm: every addon installs with `kubectl apply -f <url>`, so nothing here
|
||||
# has ever invoked it. Add it back the day something actually needs a chart.
|
||||
DEV_TOOLS="kind tilt"
|
||||
|
||||
fetch() {
|
||||
local dest="$OUT_BIN" tier="${TIER:-dev}"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--to) dest="$2"; shift 2 ;;
|
||||
core|dev) tier="$1"; shift ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
dest="$(abspath "$dest")"
|
||||
mkdir -p "$dest"
|
||||
TIER="$tier"
|
||||
|
||||
if [ "$DEPS_SOURCE" = "baked" ]; then
|
||||
echo "installing baked binaries from $BAKED_BIN"
|
||||
cp -a "$BAKED_BIN"/. "$dest"/
|
||||
fix_ownership "$dest"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "fetching '$tier' toolchain (source: $DEPS_SOURCE)"
|
||||
fetch_bin kubectl "$KUBECTL_URL" "$KUBECTL_SHA256" "$dest"
|
||||
fetch_bin jq "$JQ_URL" "$JQ_SHA256" "$dest"
|
||||
if [ "$tier" = "dev" ]; then
|
||||
fetch_bin kind "$KIND_URL" "$KIND_SHA256" "$dest"
|
||||
fetch_tgz tilt "$TILT_URL" "$TILT_SHA256" "$dest" tilt 0
|
||||
fi
|
||||
|
||||
fix_ownership "$dest"
|
||||
# kind writes the kubeconfig as root too; hand that back as well when it's
|
||||
# a mounted host directory rather than container-local state.
|
||||
fix_ownership "${KUBE_DIR:-/out/kube}"
|
||||
}
|
||||
|
||||
# ── install ────────────────────────────────────────────────────────────────
|
||||
|
||||
report_manual() {
|
||||
echo
|
||||
if [ ${#MANUAL[@]} -eq 0 ]; then
|
||||
echo "nothing left to do by hand."
|
||||
return
|
||||
fi
|
||||
echo "host actions the wizard cannot perform (${#MANUAL[@]}):"
|
||||
echo
|
||||
local n=1
|
||||
for m in "${MANUAL[@]}"; do
|
||||
echo " $n. $m"
|
||||
echo
|
||||
n=$((n + 1))
|
||||
done
|
||||
}
|
||||
|
||||
# Installing into a directory that sits early in PATH silently replaces whatever
|
||||
# the machine was already using — which on a shared or client machine can break
|
||||
# unrelated work (kubectl more than one minor away from a cluster is the common
|
||||
# one). Say so; never decide it for them.
|
||||
tier_tools() { [ "$1" = "core" ] && echo "$CORE_TOOLS" || echo "$CORE_TOOLS $DEV_TOOLS"; }
|
||||
|
||||
warn_shadowing() {
|
||||
local b existing shadowed="" tier="${1:-dev}"
|
||||
for b in $(tier_tools "$tier"); do
|
||||
[ -x "$OUT_BIN/$b" ] || continue
|
||||
# Where would this resolve if OUT_BIN weren't in the way?
|
||||
existing=$(PATH=$(echo "$PATH" | tr ':' '\n' | grep -vx "$OUT_BIN" | paste -sd:) \
|
||||
command -v "$b" 2>/dev/null || true)
|
||||
[ -n "$existing" ] || continue
|
||||
[ "$existing" = "$OUT_BIN/$b" ] && continue
|
||||
shadowed+=" $b $existing"$'\n'
|
||||
done
|
||||
|
||||
[ -n "$shadowed" ] || return 0
|
||||
|
||||
case ":${PATH}:" in
|
||||
*":$OUT_BIN:"*) ;;
|
||||
*) return 0 ;; # not on PATH yet, so nothing is being shadowed
|
||||
esac
|
||||
|
||||
echo
|
||||
echo " ! these were already installed elsewhere and are now shadowed by $OUT_BIN:"
|
||||
printf '%s' "$shadowed"
|
||||
echo " Other projects on this machine will pick up the new versions."
|
||||
MANUAL+=("Decide which toolchain wins. To keep the previous one, remove what
|
||||
was just installed:
|
||||
rm -f $(for b in $(tier_tools "$tier"); do printf '%s ' "$OUT_BIN/$b"; done)
|
||||
Or install somewhere private instead:
|
||||
OUT_BIN=\$PWD/def/bin make deps # then put that dir first in PATH")
|
||||
}
|
||||
|
||||
install() {
|
||||
local tier="${1:-dev}"
|
||||
detect
|
||||
echo
|
||||
fetch "$tier"
|
||||
echo
|
||||
echo "installed to $OUT_BIN ($tier):"
|
||||
for b in $(tier_tools "$tier"); do
|
||||
[ -x "$OUT_BIN/$b" ] && echo " $b"
|
||||
done
|
||||
if [ "$tier" = "core" ]; then
|
||||
echo " (no kind/tilt — 'make deps dev' adds them)"
|
||||
fi
|
||||
warn_shadowing "$tier"
|
||||
|
||||
case ":${PATH}:" in
|
||||
*":$OUT_BIN:"*) ;;
|
||||
*) MANUAL+=("Put the toolchain on your PATH — add to ~/.bashrc:
|
||||
export PATH=\"${OUT_BIN}:\$PATH\"") ;;
|
||||
esac
|
||||
|
||||
report_manual
|
||||
}
|
||||
|
||||
# ── main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
case "${1:-install}" in
|
||||
detect) detect; report_manual ;;
|
||||
fetch) shift; fetch "$@" ;;
|
||||
install) shift; install "${1:-dev}" ;;
|
||||
*) echo "usage: $0 [detect|fetch|install]" >&2; exit 1 ;;
|
||||
esac
|
||||
@@ -20,13 +20,13 @@ digraph rig_install {
|
||||
bin [label="~/.local/bin\nkind · kubectl · tilt\njq" fillcolor="#121829" shape=cylinder]
|
||||
}
|
||||
|
||||
subgraph cluster_wizard {
|
||||
subgraph cluster_installer {
|
||||
label="Installer container (transient)"
|
||||
style=dashed
|
||||
color="#1e2a4a"
|
||||
fontcolor="#8892a8"
|
||||
|
||||
wizard [label="wizard\ncurl · jq · python · graphviz" fillcolor="#121829"]
|
||||
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"]
|
||||
}
|
||||
@@ -34,14 +34,14 @@ digraph rig_install {
|
||||
upstream [label="upstream\nreleases / corporate mirror" fillcolor="#1a3a1a" fontcolor="#00c853" shape=octagon]
|
||||
report [label="report what it\nCANNOT do" fillcolor="#3a1a1a" fontcolor="#ffc107"]
|
||||
|
||||
docker -> wizard [label="docker run"]
|
||||
wizard -> detect
|
||||
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.
|
||||
wizard -> gone [style=dotted label="exits"]
|
||||
installer -> gone [style=dotted label="exits"]
|
||||
gone [label="(container discarded)" fillcolor="#0a0e17" fontcolor="#4a5568" color="#1e2a4a" style="filled,dashed"]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user