The installer goes as far as it can, and asks where a human must choose

Twelve steps made a mesh that RUNS and then said "what remains is somebody
else's". The seven things that turn it into a mesh that WORKS — the shared base,
a database provider, the catalogue, the private network, the packet filter — were
typed afterwards, which is how they went missing for weeks without anything
complaining.

Six more steps now: base, store, catalogue, network, filter, extras. Everything
in them is module add, build, assign and push — the same verbs a person types,
through the same commands, so the installer and an operator remain one act.

Where a human must choose, the installer asks. A choice resolves in the order a
person expects: the flag wins; a lone option answers itself ALOUD, because "it
chose for me" and "there was nothing to choose" read identically afterwards
unless one speaks; a terminal is asked; a default fills in; and a required
choice nothing answered refuses naming its flag — a guessed packet filter is a
machine somebody else configured. The filter is required, so the question is
which, not whether. A run without a terminal (the lab, --json) is never left
waiting on a prompt nobody will answer.

Placement is part of the network step, not a separate act — a lesson paid for:
the module installed, the names file was written with no names in it, and
everything reported success because nobody had said where the machine IS. The
hub endpoint derives from the broker address when unsaid: the host other
machines dial is one fact, not two that drift.

Extras fail the run rather than soft-fail: somebody asked for them by name, and
a mesh reporting success minus one thing is reporting the wrong thing.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
This commit is contained in:
2026-09-15 21:56:23 +02:00
parent 7a223464e5
commit 21474b0144
7 changed files with 550 additions and 4 deletions
+68 -3
View File
@@ -53,6 +53,12 @@ const (
StepControlPlane Step = "control-plane"
StepRetire Step = "retire"
StepBuilder Step = "builder"
StepBase Step = "base"
StepStore Step = "store"
StepCatalogue Step = "catalogue"
StepNetwork Step = "network"
StepFilter Step = "filter"
StepExtras Step = "extras"
)
// Steps in the order they happen, so a failure can say "step 2 of 11".
@@ -69,6 +75,10 @@ const (
var Steps = []Step{
StepPreflight, StepLoad, StepBuild, StepBundle, StepApply, StepVerify,
StepEnrol, StepRegistry, StepPublish, StepControlPlane, StepRetire, StepBuilder,
// Phase two. The twelve above make a mesh that RUNS; these make one that WORKS — able to
// build, to say what it holds, on its network, filtering. They used to be things somebody
// typed afterwards, which is how they went missing without anything complaining.
StepBase, StepStore, StepCatalogue, StepNetwork, StepFilter, StepExtras,
}
// Error is a failure, named by the step it happened in.
@@ -145,6 +155,24 @@ type Options struct {
// HostInBackground starts the host unsupervised instead, which is what the lab does and what no
// real machine should do — it does not survive a reboot.
HostInBackground bool
// ---- phase two — a mesh that runs becomes a mesh that works ----
// ToolsSource is where the shared base is built from. Everything with code of its own
// compiles against it, so it is the first thing the mesh builds for itself.
ToolsSource Source
// CatalogSource is where the catalogue REPOSITORY is, for building its modules. The catalogue
// CHECKOUT (Catalogue, above) says what a module is; this is where a builder clones it.
CatalogSource Source
// Site is where this machine sits, for the private network's placement.
Site string
// Answers are the choices, answered by flag: name → answer. What a terminal would be asked.
Answers map[string]string
// Prompt asks a person one choice. Nil is an unattended run: flags and defaults answer, and a
// required choice nothing answered is a refusal rather than a guess.
Prompt func(Choice) (string, error)
// Extras are catalogue modules beyond the floor, asked for by name.
Extras []string
}
// pivots reports whether this run goes past the substrate.
@@ -535,9 +563,46 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro
return result, failed(StepBuilder, err)
}
say("\nthis machine is a mesh of one node, and the control plane it runs is a module " +
"pinned to an image its own registry serves.")
say("it holds a builder, so it can make the rest of the catalogue rather than be handed it.")
// ---- 13. base -------------------------------------------------------------------------
say("base — the shared toolchain and runtime everything with code stands on")
if err := BuildBase(ctx, o, permanentControl, say); err != nil {
return result, failed(StepBase, err)
}
// ---- 14. store ------------------------------------------------------------------------
// A database PROVIDER. The substrate's store is the control plane's own memory and offers
// nothing to anything; the first thing that wants a database is the catalogue, next.
say("store — a database provider, which the substrate's own store is not")
if err := InstallFromCatalogue(ctx, o, permanentControl, "postgres", say); err != nil {
return result, failed(StepStore, err)
}
// ---- 15. catalogue --------------------------------------------------------------------
say("catalogue — the module graph: what is held, what a change reaches, what to rebuild")
if err := InstallFromCatalogue(ctx, o, permanentControl, "mesh-catalog", say); err != nil {
return result, failed(StepCatalogue, err)
}
// ---- 16. network ----------------------------------------------------------------------
say("network — the private network, and this machine's name on it")
if err := PlaceOnTheNetwork(ctx, o, permanentControl, rewritten.BrokerAddress, say); err != nil {
return result, failed(StepNetwork, err)
}
// ---- 17. filter -----------------------------------------------------------------------
say("filter — required, so the question is which, not whether")
if err := ChooseAndInstallFilter(ctx, o, permanentControl, say); err != nil {
return result, failed(StepFilter, err)
}
// ---- 18. extras -----------------------------------------------------------------------
say("extras — beyond the floor, if asked")
if err := InstallExtras(ctx, o, permanentControl, say); err != nil {
return result, failed(StepExtras, err)
}
say("\nthis machine is a mesh of one node: it builds its own software, holds its graph, " +
"sits on its private network, and filters what modules declared.")
say("what remains is somebody else's: adding nodes, and assigning what they should run.")
return result, nil
+79
View File
@@ -0,0 +1,79 @@
package bootstrap
import (
"fmt"
"strings"
)
// What the installer asks a person, and how an unattended run answers.
//
// **The installer goes as far as it can, and where a human must choose, it asks** — a packet
// filter is required, so the question is not whether but which. A run with a terminal is asked;
// a run without one (the lab, an unattended machine) answers with flags, and a required choice
// with no flag, no terminal and no lone option is a refusal rather than a guess.
// Choice is one question the installer needs answered.
type Choice struct {
// Name is the flag that answers it unattended: --packet-filter, --private-network.
Name string
// Question is what a person is asked, ending with what the options are.
Question string
// Options are the acceptable answers. Empty means free-form (an address, a list).
Options []string
// Default is used when nothing and nobody answered. Empty means the choice is required.
Default string
}
// decide resolves one choice, in the order a person would expect:
//
// an explicit answer (the flag) wins; a lone option answers itself, said aloud; a terminal is
// asked; a default fills in; and a required choice nothing answered is refused naming its flag.
func decide(c Choice, answered string, prompt func(Choice) (string, error), say func(string)) (string, error) {
if got := strings.TrimSpace(answered); got != "" {
return validated(c, got)
}
if len(c.Options) == 1 {
// The only answer there is. Said rather than silent, because "it chose for me" and "there
// was nothing to choose" read identically afterwards unless one of them says so.
say(fmt.Sprintf(" %-17s %s — the only option there is", c.Name, c.Options[0]))
return c.Options[0], nil
}
if prompt != nil {
got, err := prompt(c)
if err != nil {
return "", err
}
if strings.TrimSpace(got) == "" && c.Default != "" {
say(fmt.Sprintf(" %-17s %s (default)", c.Name, c.Default))
return c.Default, nil
}
return validated(c, strings.TrimSpace(got))
}
if c.Default != "" {
say(fmt.Sprintf(" %-17s %s (default)", c.Name, c.Default))
return c.Default, nil
}
return "", fmt.Errorf(
"%s must be chosen and nothing chose it: no --%s, no terminal to ask on%s",
c.Name, c.Name, orOptions(c))
}
func validated(c Choice, got string) (string, error) {
if len(c.Options) == 0 {
return got, nil
}
for _, option := range c.Options {
if got == option {
return got, nil
}
}
return "", fmt.Errorf("%q is not a %s this mesh offers. It has %s",
got, c.Name, strings.Join(c.Options, ", "))
}
func orOptions(c Choice) string {
if len(c.Options) == 0 {
return ""
}
return ". It offers " + strings.Join(c.Options, ", ")
}
+64
View File
@@ -0,0 +1,64 @@
package bootstrap
import (
"strings"
"testing"
)
func quietly(string) {}
// A flag answers, and answers wrongly is refused naming what would have worked.
func TestAFlagAnswersAndAWrongOneIsRefused(t *testing.T) {
filter := Choice{Name: "packet-filter", Options: []string{"nftables", "ufw"}}
got, err := decide(filter, "ufw", nil, quietly)
if err != nil || got != "ufw" {
t.Fatalf("an explicit answer was not taken: %q, %v", got, err)
}
_, err = decide(filter, "iptables", nil, quietly)
if err == nil {
t.Fatal("an answer nothing offers was accepted")
}
if !strings.Contains(err.Error(), "nftables") || !strings.Contains(err.Error(), "ufw") {
t.Fatalf("the refusal does not say what would have worked: %v", err)
}
}
// A lone option answers itself — and says so, because "it chose for me" and "there was nothing to
// choose" read identically afterwards unless one of them speaks.
func TestALoneOptionAnswersItselfAloud(t *testing.T) {
var said []string
got, err := decide(Choice{Name: "private-network", Options: []string{"wireguard"}},
"", nil, func(line string) { said = append(said, line) })
if err != nil || got != "wireguard" {
t.Fatalf("the only option was not taken: %q, %v", got, err)
}
if len(said) == 0 || !strings.Contains(said[0], "only option") {
t.Fatalf("choosing silently: %v", said)
}
}
// A terminal is asked; an empty answer takes the default when there is one.
func TestATerminalIsAskedAndEmptyTakesTheDefault(t *testing.T) {
asked := 0
prompt := func(c Choice) (string, error) { asked++; return "", nil }
got, err := decide(Choice{Name: "extras", Default: "none"}, "", prompt, quietly)
if err != nil || got != "none" || asked != 1 {
t.Fatalf("empty answer at a prompt did not take the default: %q asked=%d %v", got, asked, err)
}
}
// **Unattended and required is a refusal, not a guess.** The lab and any machine without a
// terminal must be answerable by flags — and a required choice with no flag has to stop the run
// naming the flag, because a guessed packet filter is a machine somebody else configured.
func TestUnattendedAndRequiredRefusesNamingTheFlag(t *testing.T) {
_, err := decide(Choice{Name: "packet-filter", Options: []string{"nftables", "ufw"}},
"", nil, quietly)
if err == nil {
t.Fatal("a required choice was guessed for an unattended run")
}
if !strings.Contains(err.Error(), "--packet-filter") {
t.Fatalf("the refusal does not name the flag that answers it: %v", err)
}
}
+218
View File
@@ -0,0 +1,218 @@
package bootstrap
import (
"context"
"encoding/json"
"fmt"
"net"
"strings"
"time"
)
// Phase two — a mesh that runs becomes a mesh that works.
//
// Genesis ends with a control plane, a store, a broker, a registry and a builder — a mesh that
// RUNS. It holds no module graph, has no private network, and filters nothing. Those used to be
// things somebody typed afterwards, which is how they went missing for weeks without anything
// complaining (novox/hq 03-DESIGN/01-to-be/21-the-installation-in-full.md). The installer goes as
// far as it can instead, and asks where a human must choose.
//
// Everything here is `module add`, `build`, `assign` and `push` — the same verbs a person types,
// through the same commands, so what the installer does and what an operator does remain one act.
// buildWait bounds one module build. Generous, because the first build compiles a toolchain.
const buildWait = 20 * time.Minute
// BuildBase asks the mesh to build the shared base every module with code of its own stands on.
//
// **First, because until it exists nothing else with code can be built.** Not registered as a
// module here: it is never assigned — it runs nowhere — and the build itself records what was
// made, which is all anything downstream reads.
func BuildBase(ctx context.Context, o Options, control controlPlane, say func(string)) error {
if o.ToolsSource.Repository == "" {
return fmt.Errorf("phase two needs --tools-source: the shared base is built from its " +
"own repository, and an installer told nothing cannot know where that is")
}
say(" building " + o.ToolsSource.Repository + " at " + refOr(o.ToolsSource.Ref))
_, err := control.within(buildWait).tell(ctx,
"build", o.ToolsSource.Repository, "--ref", refOr(o.ToolsSource.Ref), "--wait", "1200s")
return err
}
// InstallFromCatalogue builds a catalogue module and installs it on this machine.
//
// The order matters and is the one the lab proved: register the manifest, build (so the artifact
// exists before anything resolves it), issue its broker account (a runtime without one starts,
// parses a password as a credential document, and loops), assign, push.
func InstallFromCatalogue(ctx context.Context, o Options, control controlPlane,
module string, say func(string)) error {
manifest, err := readManifest(o.Catalogue, module)
if err != nil {
return err
}
remote := "/" + module + "-module.json"
if err := control.carrying(ctx, module+"-module.json", manifest, remote); err != nil {
return err
}
if _, err := control.tell(ctx, "module", "add", remote); err != nil {
return err
}
say(" registered " + module)
if builds(manifest) {
if o.CatalogSource.Repository == "" {
return fmt.Errorf("%s has to be built and there is no --catalog-source to build it "+
"from: the catalogue CHECKOUT says what it is, the catalogue REPOSITORY is where "+
"a builder clones it", module)
}
say(" building " + module)
if _, err := control.within(buildWait).tell(ctx, "build", o.CatalogSource.Repository,
"--path", "modules/"+module, "--ref", refOr(o.CatalogSource.Ref),
"--wait", "1200s"); err != nil {
return err
}
}
if _, err := control.tell(ctx, "module", "issue", module, "--node", o.Node); err != nil {
// Not every module consumes the broker; one that does not is refused an account and that
// is fine. Said rather than silent, so a module that SHOULD have one and was refused is
// visible here rather than as a crash-loop later.
say(" no account " + module + " — it declares nothing to say on the broker")
} else {
say(" account issued " + module)
}
if _, err := control.tell(ctx, "assign", o.Node, module); err != nil {
return err
}
if _, err := pushNode(ctx, o, control, say); err != nil {
return err
}
say(" installed " + module)
return nil
}
// PlaceOnTheNetwork chooses a private-network provider, assigns it, and places this machine as
// the hub.
//
// **Assigning is not being on the network** — a lesson paid for: the module installed, the names
// file was written with no names in it, and everything reported success, because nobody had said
// where this machine IS. So placement is part of the step, not a separate act.
func PlaceOnTheNetwork(ctx context.Context, o Options, control controlPlane,
brokerAddress string, say func(string)) error {
network, err := decide(Choice{
Name: "private-network",
Question: "Which private network should this mesh run?",
Options: []string{"wireguard"},
}, o.Answers["private-network"], o.Prompt, say)
if err != nil {
return err
}
// Today the one provider is the control plane's own computed module. The choice exists so
// that the day there are two, this asks instead of assuming.
module := "networking"
_ = network
endpoint, err := decide(Choice{
Name: "endpoint",
Question: "Where do other machines reach this one for the private network? " +
"(host:port; the host other machines dial)",
Default: derivedEndpoint(brokerAddress),
}, o.Answers["endpoint"], o.Prompt, say)
if err != nil {
return err
}
if endpoint == "" {
return fmt.Errorf("the private network needs an endpoint other machines can dial, and " +
"nothing said one: pass --endpoint, or --broker-address so one can be derived")
}
if _, err := control.tell(ctx, "assign", o.Node, module); err != nil {
return err
}
if _, err := control.tell(ctx, "overlay", "place", o.Node,
"--hub", "--endpoint", endpoint, "--site", o.Site); err != nil {
return err
}
if _, err := pushNode(ctx, o, control, say); err != nil {
return err
}
say(" on the network " + o.Node + " is the hub, at " + endpoint)
return nil
}
// ChooseAndInstallFilter picks the packet filter — required, so the question is which, not
// whether — and installs it.
func ChooseAndInstallFilter(ctx context.Context, o Options, control controlPlane, say func(string)) error {
filter, err := decide(Choice{
Name: "packet-filter",
Question: "Which packet filter should this machine run?",
Options: []string{"nftables"},
}, o.Answers["packet-filter"], o.Prompt, say)
if err != nil {
return err
}
return InstallFromCatalogue(ctx, o, control, filter, say)
}
// InstallExtras installs what was asked for beyond the floor.
//
// One refusal per act: an extra that cannot be installed fails the run, because somebody asked
// for it by name and a mesh that reports success minus one thing is reporting the wrong thing.
func InstallExtras(ctx context.Context, o Options, control controlPlane, say func(string)) error {
asked, err := decide(Choice{
Name: "extras",
Question: "Anything beyond the floor? (comma-separated catalogue modules — " +
"gitea, step-ca, dnsmasq — or nothing)",
Default: "none",
}, strings.Join(o.Extras, ","), o.Prompt, say)
if err != nil {
return err
}
if asked == "" || asked == "none" {
say(" extras none")
return nil
}
for _, extra := range strings.Split(asked, ",") {
if extra = strings.TrimSpace(extra); extra == "" {
continue
}
if err := InstallFromCatalogue(ctx, o, control, extra, say); err != nil {
return fmt.Errorf("%s was asked for and could not be installed: %w", extra, err)
}
}
return nil
}
// builds says whether a manifest declares anything to build.
func builds(manifest []byte) bool {
var m struct {
Build *struct {
Artifacts []json.RawMessage `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(manifest, &m); err != nil {
return false
}
return m.Build != nil && len(m.Build.Artifacts) > 0
}
// derivedEndpoint is the default place other machines dial for the private network: the same host
// they already dial for the broker, on WireGuard's ordinary port. One fact, not two.
func derivedEndpoint(brokerAddress string) string {
host, _, err := net.SplitHostPort(brokerAddress)
if err != nil || host == "" {
return ""
}
return net.JoinHostPort(host, "51820")
}
func refOr(ref string) string {
if ref == "" {
return "main"
}
return ref
}
+25
View File
@@ -0,0 +1,25 @@
package bootstrap
import "testing"
// The endpoint other machines dial defaults to the host they already dial — the broker's — on
// WireGuard's port. One fact, not two that drift.
func TestTheEndpointDerivesFromTheBrokerAddress(t *testing.T) {
if got := derivedEndpoint("192.0.2.10:5671"); got != "192.0.2.10:51820" {
t.Fatalf("derived %q", got)
}
if got := derivedEndpoint(""); got != "" {
t.Fatalf("an endpoint was invented from nothing: %q", got)
}
}
// A manifest with artifacts builds; one without does not — which is what separates postgres (a
// bundle to compile) from nftables (a package and a service).
func TestOnlyAManifestWithArtifactsBuilds(t *testing.T) {
if !builds([]byte(`{"build":{"artifacts":[{"name":"x"}]}}`)) {
t.Fatal("a manifest with artifacts was not built")
}
if builds([]byte(`{"resources":[{"id":"p","type":"package","package":"nftables"}]}`)) {
t.Fatal("a manifest with nothing to build was built anyway")
}
}