A real bundle: a bare machine raises a store and a database

The first three steps of the substrate bootstrap, run on a lab machine
confirmed to have no route out. It went from bare to a container runtime
installed and enabled, PostgreSQL running from an image pinned by digest, and
the control plane's database created inside it -- from the file the host
carries, with nothing to ask.

Second run changed nothing. `owned` lists all five afterwards, and `mesh` is in
the store.

It stops before the last two steps because there is no control plane yet: its
schema cannot be loaded and its image does not exist. The bundle says so rather
than naming something that cannot be applied.

Two things fixed on the way.

`make host BUNDLE=...` still swapped a file called substrate.lock, which the
per-system split had renamed months of decisions ago -- it now takes SYSTEM and
replaces that system's bundle. And the .lock files still cited ADR 0060, since
the renumbering pass only covered .md, .go, .ts and .sh.

One thing learned by it failing first: a directory the host creates is owned by
root, and a database inside a container runs as somebody else, so it could not
write and the container crash-looped. The store's data is a named volume now,
which lets the image set up its own ownership and outlives the container --
which is what you want for the thing holding the mesh's state.

Worth noting the failure was caught by the action's verify rather than by the
container step. `docker inspect` reported the container running because it was,
briefly, between restarts. Running is not working, and the thing that knew the
difference was the step that asked the database whether it would answer.
This commit is contained in:
2026-08-29 01:54:10 +02:00
parent 430e2a1271
commit e09503acc7
6 changed files with 96 additions and 8 deletions
+10 -5
View File
@@ -39,17 +39,22 @@ test:
build:
CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o mesh-host ./cmd/mesh-host
# A host for a real machine, carrying a real bundle: make host BUNDLE=path/to/substrate.lock
# A host for a real machine, carrying a real bundle:
# make host SYSTEM=arch BUNDLE=path/to/substrate.lock
#
# The bundle replaces the one for SYSTEM, because its contents are per operating system —
# package names and unit names differ (novox/hq ADR 0005).
host:
@test -n "$(BUNDLE)" || { echo "BUNDLE= is required; a host with no bundle cannot raise a first node"; exit 1; }
@test -f "$(BUNDLE)" || { echo "no such bundle: $(BUNDLE)"; exit 1; }
@cp internal/bundle/substrate.lock internal/bundle/substrate.lock.default
@cp "$(BUNDLE)" internal/bundle/substrate.lock
@test -f internal/bundle/substrate-$(SYSTEM).lock || { echo "no bundle slot for SYSTEM=$(SYSTEM)"; exit 1; }
@cp internal/bundle/substrate-$(SYSTEM).lock internal/bundle/substrate-$(SYSTEM).lock.default
@cp "$(BUNDLE)" internal/bundle/substrate-$(SYSTEM).lock
@CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o mesh-host ./cmd/mesh-host; \
status=$$?; \
mv internal/bundle/substrate.lock.default internal/bundle/substrate.lock; \
mv internal/bundle/substrate-$(SYSTEM).lock.default internal/bundle/substrate-$(SYSTEM).lock; \
exit $$status
@echo "built carrying $(BUNDLE)"
@echo "built for $(SYSTEM) carrying $(BUNDLE)"
clean:
rm -f mesh-host
+26
View File
@@ -0,0 +1,26 @@
# Examples
## `substrate-first-node.lock`
What an Arch machine must be before a mesh exists — the first steps of the bootstrap in
[novox/hq `07-the-substrate.md`](https://git.novox.be/novox/hq): a container runtime, a store
running, and the control plane's database created inside it.
**It stops there**, and the file says why: loading the control plane's schema and starting the
control plane are the next two steps, and there is no control plane yet. A bundle naming one
would be a bundle that cannot be applied.
Build a host carrying it:
```
make host SYSTEM=arch BUNDLE=examples/substrate-first-node.lock
```
**The image reference has to be replaced before this is useful.** It is written as
`REGISTRY/postgres@DIGEST` because the digest belongs to whatever registry serves it — in the
lab, one the scenario raises, which reports its digests when it comes up. That is not a
placeholder to be tidied away: a bundle is built *for a target*, and which registry that target
pulls from is part of the target.
Verified end to end in a lab machine with no route out: five resources applied, idempotent on a
second run, and `mesh` present in the store afterwards.
+57
View File
@@ -0,0 +1,57 @@
// substrate-arch.lock — what an Arch machine must be before a mesh exists.
//
// The first three steps of the bootstrap (novox/hq 03-DESIGN/01-to-be/07-the-substrate.md).
// Steps four and five — load the control plane's schema, start the control plane — are absent
// because the control plane does not exist yet. A bundle that named it would be a bundle that
// cannot be applied.
//
// PINNED BY DIGEST, and the digest is not decoration: a tag can be made to point at a different
// image, and this file is applied on a machine with no mesh to ask about anything.
//
// The store's data is a NAMED VOLUME rather than a directory on the machine. A directory the
// host creates is owned by root, and the database runs as somebody else inside the container —
// so it could not write, and the container crash-looped. A named volume lets the image set up
// its own ownership, which is what it is for. It also outlives the container, which is what you
// want for the thing holding the mesh's state.
{
"declaration": 1,
"resources": [
{
"id": "container-runtime",
"type": "package",
"package": "docker"
},
{
"id": "container-runtime-running",
"type": "service",
"unit": "docker.service",
"state": "running",
"boot": "enabled"
},
{
"id": "store",
"type": "container",
"name": "mesh-store",
"image": "REGISTRY/postgres@DIGEST",
"env": {
"POSTGRES_PASSWORD": "bootstrap",
"PGDATA": "/var/lib/postgresql/data/pgdata"
},
"volumes": ["mesh-store-data:/var/lib/postgresql/data"]
},
{
"id": "store-ready",
"type": "action",
"in": "mesh-store",
"command": ["sh", "-c", "for i in $(seq 1 60); do pg_isready -U postgres >/dev/null 2>&1 && exit 0; sleep 1; done; exit 1"],
"verify": ["pg_isready", "-U", "postgres"]
},
{
"id": "control-plane-database",
"type": "action",
"in": "mesh-store",
"command": ["sh", "-c", "psql -U postgres -c 'CREATE DATABASE mesh'"],
"verify": ["sh", "-c", "psql -U postgres -lqt | cut -d'|' -f1 | grep -qw mesh"]
}
]
}
+1 -1
View File
@@ -1,7 +1,7 @@
// substrate-alpine.lock — the pinned tier-1 descriptor the ALPINE host carries.
//
// Per system, because its CONTENTS are: this one names apk packages and OpenRC services where
// the arch bundle names pacman packages and systemd units (novox/hq ADR 0060).
// the arch bundle names pacman packages and systemd units (novox/hq ADR 0005).
//
// Empty on purpose. What belongs here is the closure for a one-node mesh, and that is not
// yet known: novox/hq research 012 asks what the minimum actually is, and research 011 is
+1 -1
View File
@@ -5,7 +5,7 @@
//
// The substrate is a container runtime, a store and the control plane (novox/hq
// 07-the-substrate.md). An android host implements `file`, `directory` and `action` and refuses
// `package`, `container` and `service` (ADR 0060) — so every step of the bootstrap is a shape it
// `package`, `container` and `service` (ADR 0005) — so every step of the bootstrap is a shape it
// does not have. No amount of filling this in changes that.
//
// **A partial host can JOIN a mesh and cannot BE the first node.** That is a real distinction
+1 -1
View File
@@ -1,7 +1,7 @@
// substrate-arch.lock — the pinned tier-1 descriptor the ARCH host carries.
//
// Per system, because its CONTENTS are: package names, unit names and service names all differ
// (novox/hq ADR 0060). The mechanism is shared; what it names is not.
// (novox/hq ADR 0005). The mechanism is shared; what it names is not.
//
// Empty on purpose. What belongs here is the closure for a one-node mesh, and that is not
// yet known: novox/hq research 012 asks what the minimum actually is, and research 011 is