SmartestHome/tools/README.md

171 lines
9.0 KiB
Markdown

# tools — the build system
Every build and setup script for this project, driven by **one** config file at the
repo root.
```sh
cp CoreSystemConfig.json.template CoreSystemConfig.json
$EDITOR CoreSystemConfig.json
tools/validate-config.py # check it before you commit to a long build
sudo -E tools/build-all.sh # build everything
```
That's the whole workflow.
## Why this exists
Before it, the same facts lived in six places. The container host's IP was written into
four ISO builders; `IDENTITY_TOKEN` had to match by hand across three; every service URL
was a hand-typed string with a port in it. Any one of them could be wrong, and the
symptom was always the same and always late: an image that boots fine and then can't
reach something, discovered after a 40-minute build and a reboot.
Two changes fix that class of bug:
1. **Nothing is written twice.** No script in this directory contains an IP address, a
port or a token. They read `CoreSystemConfig.json`.
2. **Anything derivable is derived.** You give the subnet prefix once and one last
octet per host. Every address and every service URL is computed from those.
## The twinned pair
The container host and the LLM host are built as a matched set, and the twinning is
mechanical rather than a matter of remembering:
```
network.subnet_prefix 192.168.30
container_host.ip_last_octet 12 -> 192.168.30.12
llm_host.ip_last_octet 13 -> 192.168.30.13
...so the container host's OLLAMA_HOST is http://192.168.30.13:11434
because it was COMPUTED from the LLM host's octet in the same build,
not because someone typed the same address into two files.
```
Move the LLM host to `.21` and rebuild: the container host's Ollama URL follows on its
own. Change the subnet prefix and *both* halves move, along with every kiosk's service
URLs. **Neither image can be built pointing at an address the other one isn't using.**
Both halves are stamped with the same `SMARTHOME_PAIR_ID` in `/etc/smarthome-build`, so
two USB sticks found in a drawer months later can be checked against each other. The ID
is a hash of the config's *meaning*, not its bytes — reformatting the JSON doesn't
change it, moving a host does.
## The validator
`validate-config.py` runs before every build and refuses to start on an error, so a
mistake costs seconds instead of an hour. It's stdlib-only on purpose: it has to be
able to run on a fresh checkout before anything is installed.
What it catches, beyond missing and malformed values:
| Check | Why it matters |
|---|---|
| **Duplicate ports** | Two services on one port means a container that silently fails to bind, or whichever won last boot answering. Miserable to diagnose from the symptom — and it's how `music_assistant` (default 8095) collides with `pantry_vision`, which Compose's own check never catches because Music Assistant runs `network_mode: host` |
| Both core hosts on one address | The twinning's single assumption |
| A host colliding with the gateway | — |
| Duplicate kiosk hostnames | They identify devices on the network and in HA |
| Placeholder or low-entropy tokens | Caught *before* the length check, so padding `changeme` out to 32 characters doesn't sneak past |
| A private key pasted where the public key goes | — |
| A kiosk that needs a disabled service | A door panel built against `enable.identity: false` builds fine and fails at runtime |
| Wi-Fi SSID without a PSK, MQTT user without a password | — |
| `192.168.0.x` / `192.168.1.x` subnets (warning) | Collides with typical café and hotel LANs, which breaks a WireGuard split tunnel routing that range — see `docs/network-integration.md` §2.1 |
Warnings print but don't block. Errors block and nothing is written.
## What's here
| Script | Builds |
|---|---|
| **`build-all.sh`** | **Everything. The normal entry point**`--core`, `--kiosks`, `--dry-run` |
| `build-core-pair.sh` | The twinned container host + LLM host |
| `build-container-host-iso.sh` | Just the container host |
| `build-llm-host-iso.sh` | Just the LLM host |
| `build-door-panel-iso.sh` etc. | One kiosk; takes a hostname when several of a type are configured |
| `build-audio-endpoint-iso-amd64.sh` | An amd64 audio endpoint (mini PC, live-build → `.iso`) |
| `build-audio-endpoint-image-arm64.sh` | An arm64 audio endpoint (Pi + HiFiBerry, rpi-image-gen → `.img`) |
| `setup-container-host.sh` | The container-host setup itself, run by its ISO's first-boot unit (or by hand) |
| `setup-llm-host.sh` | Same, for the LLM host |
| `validate-config.py` | Check the config |
| `config-export.py` | Config → shell variables, deriving URLs. Where the twinning happens |
| `lib/coreconfig.sh` | The loader every builder sources |
Building one image is supported but unusual: the images are a set that has to agree
with itself, which is why `build-all.sh` is the default and a failure in one image
doesn't abandon the rest.
### One entry, one image
Kiosks and audio endpoints come from **arrays** in the config, and every entry gets its
own artifact in `iso-out/`, named `smarthome-<hostname>-<pairid>.iso`. That naming
is load-bearing rather than cosmetic: live-build always writes the same filename into
the same per-host tree, so two thin clients would otherwise have the second silently
overwrite the first — leaving one ISO carrying the second room's hostname and nothing
to indicate the first was lost.
Per-type builders take a hostname when several of that type exist, and refuse with the
list of real ones if you omit it or get it wrong.
**The one deliberate exception is the arm64 audio endpoint.** Raspberry Pi Imager sets
hostname and Wi-Fi per unit at flash time, so one generic `.img` serves every room and
`build-all.sh` builds it once no matter how many arm64 entries are listed. amd64 has no
equivalent tool for a generic x86 ISO, so it bakes the hostname in and genuinely needs
one ISO per room — see `docs/project-plan.md` Phase 15.5.
## The ISOs contain secrets
This is deliberate — burning everything in is what makes installation unattended, with
no env files to edit on a freshly-booted host. It also means **every ISO is a
credential**: Wi-Fi PSK, service tokens, MQTT and HA credentials, all readable by
anyone holding the stick.
`.gitignore` covers `CoreSystemConfig.json` and `iso-out/`, so neither can be
committed by accident. Wiping old USB sticks is on you.
## Two things can't be burnt in
Neither exists at build time, so both need a human afterwards:
1. **`HA_TOKEN`** — a Long-Lived Access Token from Home Assistant's own UI, which
doesn't exist until HA has been started and an account created. Put it in the config
and rebuild, or edit `identity.env` on the container host.
2. **`TRUSTED_ENTITY_PREFIXES`** — the real entity_id prefixes your Private BLE Device
setup produces (Developer Tools → States). The shipped default is a guess and it's
the highest-risk unknown in Phase 6.
## Adding a service or a kiosk
- **A new port**: add it to `ports` and reference it in `config-export.py`'s derived
URLs. The duplicate check covers it from then on.
- **Another audio endpoint**: add an entry to `audio_endpoints` with its `arch`.
`build-all.sh` builds every entry, so listing both an `amd64` and an `arm64` one
gets you both — they're separate toolchains producing different artifacts, not one
image that runs on both.
- **Another kiosk of an existing type**: add an entry to `kiosks` with its own
hostname. `build-all.sh` picks it up; the per-type builder takes the hostname as an
argument.
- **A new kiosk type**: add it to `KIOSK_TYPES` in `validate-config.py` and add a
`build-<type>-iso.sh`. `build-all.sh` finds it by naming convention.
## Manual verification still outstanding
1. **No ISO has ever been built with this.** `lb build` needs live-build, root, and a
long network fetch; none of that has been run. What *has* been tested is everything
up to that point: config validation and URL derivation (43 checks), and the builders'
generated artifacts — env files, preseed, network config, `/etc/hosts`, first-boot
units, build stamps — produced by the real code paths with only `lb` stubbed (44
checks). The `lb config`/`lb build` invocations themselves are unverified.
2. **The preseed files are written from Debian's documented shape, not tested.** An
unattended install that gets a preseed key wrong typically stops at an interactive
prompt rather than failing loudly, so budget for a monitor on the first install.
3. **`partman-auto/disk` erases the configured disk without confirmation.** That is
what unattended means, and it's why `install_disk` is worth double-checking against
the actual machine you boot it on.
4. **Static addressing assumes `eth0`.** Debian's predictable interface naming may well
call it `enp3s0` on your hardware, in which case
`/etc/network/interfaces.d/smarthome` needs the real name.
5. **The kiosk builders' migration is untested end-to-end.** Their config blocks now
read from `CoreSystemConfig.json`, but the body of each script is unchanged from when
it worked with hand-edited constants — so the risk is confined to the mapping, not
to image contents.