From 1f0fb8512815e29366e14dca0b413cedf7c2f457 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 15 Sep 2026 02:29:33 +0200 Subject: [PATCH 01/12] A daemon says what to run, not how it is hosted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mechanism was leaking into every module. Code of one's own meant a container and therefore an image; a script meant a service and a unit somebody else had to install. One intent — run this and keep it running — expressed two unrelated ways, with the hosting chosen before anything could be declared. A daemon names a bundle and a command. The host fetches it, refuses it unless it hashes to what was declared, unpacks it where the mesh keeps such things, writes the unit and puts it in the state asked for. The unit is the mesh's, generated whole and saying so, because an edit that survives until the next declaration and then vanishes is worse than one that is refused. Its identity is the bytes AND how it is run: two daemons from one bundle differing only in their command are different daemons, and tracking the digest alone would call the second unchanged and leave the first running. The unit is rendered deterministically for the same reason — environment from a map would be written in Go's iteration order, so every apply would see a different unit and restart an unchanged daemon for ever. restart-on is honoured as a service's is: a running process does not re-read its configuration, so replacing a file and finding the daemon already up leaves the machine behaving as before while every check passes. A full-host shape, not a portable one: it needs a process supervisor to install into. It does NOT need a container runtime, which is the point. Two guards caught this properly and both were updated deliberately rather than silenced: the vocabulary count, which exists because every addition widens what a compromised control plane can express, and the shape test that catches a kind the language has and a host cannot apply — added after `network` did exactly that. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- internal/apply/apply.go | 2 + internal/apply/daemon.go | 172 +++++++++++++++++++++++ internal/apply/daemon_test.go | 82 +++++++++++ internal/declaration/daemon_test.go | 72 ++++++++++ internal/declaration/declaration.go | 95 ++++++++++++- internal/declaration/declaration_test.go | 19 ++- internal/system/system.go | 5 + 7 files changed, 441 insertions(+), 6 deletions(-) create mode 100644 internal/apply/daemon.go create mode 100644 internal/apply/daemon_test.go create mode 100644 internal/declaration/daemon_test.go diff --git a/internal/apply/apply.go b/internal/apply/apply.go index ecb4d26..6435005 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -270,6 +270,8 @@ func applyOne(ctx context.Context, sys system.System, r declaration.Resource, ru return applyUser(ctx, sys, res, run) case *declaration.Archive: return applyArchive(ctx, res, previous) + case *declaration.Daemon: + return applyDaemon(ctx, res, run, changed, previous) case *declaration.Action: return applyAction(ctx, res, run) case *declaration.Network: diff --git a/internal/apply/daemon.go b/internal/apply/daemon.go new file mode 100644 index 0000000..1d7d0dc --- /dev/null +++ b/internal/apply/daemon.go @@ -0,0 +1,172 @@ +package apply + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/novox/mesh-host/internal/declaration" + "github.com/novox/mesh-host/internal/store" +) + +// Running the mesh's own code, without the module choosing how. +// +// **A daemon is an intent and this is one answer to it.** A module says what to run and the +// machine's own supervisor is how — which means the module is not writing a unit file, and not +// choosing between a container and a service before it can declare anything +// (novox/hq 03-DESIGN/01-to-be/18-building-a-module.md). +// +// What this does, in order: fetch the bundle, refuse it unless it hashes to what was declared, +// unpack it where the mesh keeps such things, write the unit, and put it in the state asked for. +// The unit is the mesh's — an operator editing it loses the edit at the next declaration, which is +// the same rule every managed file on a machine follows (ADR 0011). + +// daemonRoot is where unpacked daemons live. +// +// Under the mesh's own directory rather than somewhere a distribution owns: these are files the +// mesh puts there and replaces, and putting them where a package manager also writes is how two +// owners end up disagreeing about one path. +const daemonRoot = "/var/lib/mesh/daemons" + +// unitDir is where the mesh writes the units it owns. +const unitDir = "/etc/systemd/system" + +func applyDaemon(ctx context.Context, r *declaration.Daemon, run Runner, + changed map[string]bool, previous store.Applied) (Outcome, error) { + out := begin(r) + out.Action = "unchanged" + + body, err := fetch(ctx, r.Source) + if err != nil { + return out, err + } + sum := sha256.Sum256(body) + got := "sha256:" + hex.EncodeToString(sum[:]) + if got != r.Digest { + // Refused before anything is written or started. What is at that address is not what was + // declared, and running it would be running something nobody reviewed. + return out, fmt.Errorf( + "%s was declared as %s and what arrived is %s; nothing was unpacked or started", + r.Source, r.Digest, got) + } + + // **The identity of a daemon is its bytes AND how it is run.** Two daemons from one bundle + // differing only in their command are different daemons, and a record that tracked the digest + // alone would call the second one unchanged. + want := got + " " + unitFor(r) + at := filepath.Join(daemonRoot, r.Name) + + // **Something it reads changed, so it must be restarted even though it is unchanged.** A + // running process does not re-read its configuration: replace the file, find the daemon + // already up, do nothing, and the machine keeps behaving the way it did before while every + // check passes. The same rule a service follows, for the same reason. + var because string + for _, id := range r.RestartOn { + if changed[id] { + because = id + break + } + } + + if previous.Wrote == want && because == "" { + // Everything about it is as declared. Still asked whether it is RUNNING, because a + // declaration that is satisfied by a record rather than by the machine is how a stopped + // service reports success. + if active, err := run(ctx, "systemctl", "is-active", "--quiet", r.Name+".service"); err == nil { + _ = active + return out, nil + } + if _, err := run(ctx, "systemctl", "start", r.Name+".service"); err != nil { + return out, fmt.Errorf("%s is installed and would not start: %w", r.Name, err) + } + out.Action = "updated" + out.Detail = "restarted a daemon that had stopped" + out.wrote = want + return out, nil + } + + // Replaced rather than merged: the bundle is the whole of what it runs, and files left from a + // previous version would be loaded by a runtime that walks a directory. + if err := os.RemoveAll(at); err != nil { + return out, err + } + if err := os.MkdirAll(at, 0o755); err != nil { + return out, err + } + written, err := unpack(body, at) + if err != nil { + return out, err + } + if err := ownAll(at, r.User); err != nil { + return out, err + } + + unit := filepath.Join(unitDir, r.Name+".service") + if err := os.WriteFile(unit, []byte(unitFor(r)), 0o644); err != nil { + return out, err + } + if _, err := run(ctx, "systemctl", "daemon-reload"); err != nil { + return out, err + } + // Enabled and restarted, in that order: enabled so it survives a reboot, restarted rather than + // started because this path is also how a new version arrives and the old one is still running. + if _, err := run(ctx, "systemctl", "enable", r.Name+".service"); err != nil { + return out, err + } + if _, err := run(ctx, "systemctl", "restart", r.Name+".service"); err != nil { + return out, fmt.Errorf("%s was installed and would not start: %w", r.Name, err) + } + + out.Action = "updated" + if previous.Wrote == "" { + out.Action = "created" + } + out.Detail = fmt.Sprintf("%d file(s), running as %s.service", written, r.Name) + if because != "" { + out.Detail += ", restarted because " + because + " changed" + } + out.wrote = want + return out, nil +} + +// unitFor is the unit the mesh writes for a daemon. +// +// **Generated whole and never edited in place**, the same rule as every other managed file: an +// edit survives until the next declaration and then vanishes, which is worse than not being +// allowed at all, so the file says so. +// +// Deterministic — environment sorted — because this string is half the daemon's identity, and a +// map iterated in Go's order would make every apply look like a change. +func unitFor(r *declaration.Daemon) string { + var b strings.Builder + b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever the\n") + b.WriteString("# declaration changes, and an edit would survive until then and vanish.\n") + b.WriteString("[Unit]\n") + fmt.Fprintf(&b, "Description=%s, a mesh daemon\n", r.Name) + b.WriteString("After=network-online.target\n") + b.WriteString("Wants=network-online.target\n\n") + + b.WriteString("[Service]\n") + b.WriteString("Type=simple\n") + fmt.Fprintf(&b, "WorkingDirectory=%s\n", filepath.Join(daemonRoot, r.Name)) + for _, file := range r.EnvFile { + fmt.Fprintf(&b, "EnvironmentFile=%s\n", file) + } + for _, key := range sortedKeys(r.Env) { + fmt.Fprintf(&b, "Environment=%s=%s\n", key, r.Env[key]) + } + if r.User != "" { + fmt.Fprintf(&b, "User=%s\n", r.User) + } + fmt.Fprintf(&b, "ExecStart=%s\n", strings.Join(r.Run, " ")) + // Restarted when it exits, because a daemon that stops is not a daemon. Delayed, so a process + // that fails at once does not spin the machine. + b.WriteString("Restart=always\nRestartSec=5\n\n") + + b.WriteString("[Install]\nWantedBy=multi-user.target\n") + return b.String() +} diff --git a/internal/apply/daemon_test.go b/internal/apply/daemon_test.go new file mode 100644 index 0000000..2103818 --- /dev/null +++ b/internal/apply/daemon_test.go @@ -0,0 +1,82 @@ +package apply + +import ( + "strings" + "testing" + + "github.com/novox/mesh-host/internal/declaration" +) + +func aDaemon() *declaration.Daemon { + return &declaration.Daemon{ + ID: "server", Type: declaration.TypeDaemon, Name: "greeter", + Source: "https://store.invalid/greeter/daemon", + Digest: "sha256:" + strings.Repeat("a", 64), + Run: []string{"node", "index.js"}, + Env: map[string]string{"MESH_NODE": "anchor", "A_FIRST": "1"}, + } +} + +// The unit the mesh writes says what it runs, where, and that it comes back. +func TestTheUnitRunsWhatTheDaemonSaid(t *testing.T) { + unit := unitFor(aDaemon()) + for _, want := range []string{ + "ExecStart=node index.js", + "WorkingDirectory=/var/lib/mesh/daemons/greeter", + "Restart=always", + "WantedBy=multi-user.target", + } { + if !strings.Contains(unit, want) { + t.Fatalf("the unit does not say %q:\n%s", want, unit) + } + } +} + +// **Generated whole and saying so.** Every managed file on a machine carries this, because an edit +// that survives until the next declaration and then vanishes is worse than one that is refused. +func TestTheUnitSaysItIsTheMeshs(t *testing.T) { + unit := unitFor(aDaemon()) + if !strings.HasPrefix(unit, "#") || !strings.Contains(unit, "Do not edit") { + t.Fatalf("the unit does not say it is generated:\n%s", unit) + } +} + +// **Deterministic, because the unit is half the daemon's identity.** Environment held in a map +// would be written in Go's iteration order, so every apply would see a different unit and call an +// unchanged daemon changed — restarting it on every declaration for ever. +func TestTheUnitIsTheSameEveryTime(t *testing.T) { + first := unitFor(aDaemon()) + for i := 0; i < 20; i++ { + if again := unitFor(aDaemon()); again != first { + t.Fatalf("two renderings of one daemon differ:\n%s\n---\n%s", first, again) + } + } + // And sorted, so the order is a decision rather than luck. + if strings.Index(first, "A_FIRST") > strings.Index(first, "MESH_NODE") { + t.Fatalf("environment is not in a stable order:\n%s", first) + } +} + +// **Two daemons from one bundle differing only in their command are different daemons.** Tracking +// the digest alone would call the second one unchanged and leave the first one running. +func TestADaemonsIdentityIncludesHowItIsRun(t *testing.T) { + one := aDaemon() + two := aDaemon() + two.Run = []string{"node", "other.js"} + if unitFor(one) == unitFor(two) { + t.Fatal("two daemons with different commands render one unit, so a change would be missed") + } +} + +// A daemon that runs as somebody says so, and one that does not says nothing — rather than naming +// root explicitly, which would be a claim the mesh does not need to make. +func TestADaemonRunsAsWhoItSaid(t *testing.T) { + as := aDaemon() + as.User = "greeter" + if !strings.Contains(unitFor(as), "User=greeter") { + t.Fatalf("the unit does not run as the user it named:\n%s", unitFor(as)) + } + if strings.Contains(unitFor(aDaemon()), "User=") { + t.Fatalf("a daemon that named no user had one written for it:\n%s", unitFor(aDaemon())) + } +} diff --git a/internal/declaration/daemon_test.go b/internal/declaration/daemon_test.go new file mode 100644 index 0000000..d741753 --- /dev/null +++ b/internal/declaration/daemon_test.go @@ -0,0 +1,72 @@ +package declaration + +import ( + "strings" + "testing" +) + +func aDaemon() *Daemon { + return &Daemon{ + ID: "server", Type: TypeDaemon, Name: "greeter", + Source: "https://store.invalid/greeter/daemon", + Digest: "sha256:" + strings.Repeat("a", 64), + Run: []string{"node", "index.js"}, + } +} + +// A daemon is part of the vocabulary, or a declaration carrying one is refused whole. +func TestADaemonIsSomethingTheHostSpeaks(t *testing.T) { + var found bool + for _, kind := range Vocabulary() { + if kind == TypeDaemon { + found = true + } + } + if !found { + t.Fatal("a daemon cannot be declared, so a module that declares one is refused") + } + if newOf(TypeDaemon) == nil { + t.Fatal("the decoder has no daemon, so one would be refused as an unknown kind") + } +} + +// **Pinned by digest, like everything else that crosses a network.** A bundle fetched by a +// reference somebody can repoint is not pinned, and it is the one thing on a machine that would +// then be running code nobody reviewed. +func TestADaemonsBundleMustBePinned(t *testing.T) { + for _, bad := range []string{"", "latest", "sha256:short", strings.Repeat("a", 64)} { + d := aDaemon() + d.Digest = bad + if problems := d.validate("a daemon", false); len(problems) == 0 { + t.Fatalf("a daemon pinned by %q was accepted", bad) + } + } +} + +// What to run is named, never inferred. Guessing an entrypoint from which files are present makes +// a daemon change what it runs when somebody adds a file. +func TestADaemonMustSayWhatToRun(t *testing.T) { + d := aDaemon() + d.Run = nil + if problems := d.validate("a daemon", false); len(problems) == 0 { + t.Fatal("a daemon with no command was accepted") + } +} + +// Its name becomes a unit name and a path, so a separator in it would write somewhere nobody meant. +func TestADaemonsNameCannotEscapeItsUnit(t *testing.T) { + for _, bad := range []string{"", "../escape", "two words", "a/b"} { + d := aDaemon() + d.Name = bad + if problems := d.validate("a daemon", false); len(problems) == 0 { + t.Fatalf("a daemon called %q was accepted", bad) + } + } +} + +// And a well-formed one is accepted, or the tests above prove only that everything is refused. +func TestAWellFormedDaemonIsAccepted(t *testing.T) { + if problems := aDaemon().validate("a daemon", false); len(problems) != 0 { + t.Fatalf("a well-formed daemon was refused: %v", problems) + } +} diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index 84a6824..26c94a3 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -59,6 +59,20 @@ const ( // (04-ISSUES/026) — and leaves everything about it alone. Several modules declaring one // access is ordinary, because none of them owns it. TypeAccess Type = "access" + + // TypeDaemon is a long-running process the mesh keeps running, named by what it runs rather + // than by how it is hosted. + // + // **The intent, not the mechanism.** Until this, an author decided the hosting before they + // could declare anything: code of their own meant a `container` built from an image, a script + // meant a `service` and a unit somebody else had to install. Same intent — run this and keep + // it running — expressed two unrelated ways, and the choice baked into which kind was picked. + // + // A daemon names an artifact and what to run. The mesh unpacks the artifact where it keeps + // such things, writes the unit, and puts it in the state asked for. One module may declare + // several, in different languages, because a module is one piece of software and not one + // process (novox/hq ADR 0040). + TypeDaemon Type = "daemon" ) // Resource is one thing that should be true of the machine. @@ -393,6 +407,81 @@ func (a *Archive) validate(where string, _ bool) []string { return problems } +// Daemon is a long-running process the mesh installs, keeps running, and owns the unit for. +// +// The difference from Service is who owns the unit: a Service puts an EXISTING unit into a state +// and deliberately does not install one, which is right for software that ships its own. A Daemon +// is the mesh's own code — a bundle it built — so there is no unit until the mesh writes it, and +// nothing else will. +// +// The difference from Container is the hosting, and a module should not have to choose: what a +// daemon says is what to run, and the machine's own process supervisor is how. A module whose code +// genuinely needs a container's isolation declares a container and says so. +type Daemon struct { + ID string `json:"id"` + Type Type `json:"type"` + // Name is what the unit is called, and what an operator will see in the process table. + Name string `json:"name"` + // Source is where to fetch the bundle from, and Digest is what it must hash to. The same + // discipline as an archive, for the same reason: this crosses a network the mesh does not + // control. + Source string `json:"source"` + Digest string `json:"digest"` + // Run is the command, relative to the unpacked bundle. The first element is the program. + // + // **Named by the module, never inferred.** Guessing an entrypoint from which files exist makes + // a daemon change what it runs when somebody adds a file. + Run []string `json:"run"` + // Env and EnvFile are what it runs with. A file rather than inline values is how a credential + // reaches a daemon without passing through the declaration. + Env map[string]string `json:"env,omitempty"` + EnvFile []string `json:"env-file,omitempty"` + // User is who it runs as. Absent means root, which is what the mesh's own modules need for + // the things they do to a machine. + User string `json:"user,omitempty"` + // RestartOn names resources whose change means this must be restarted — the same rule a + // service follows, and for the same reason: a running process does not re-read its + // configuration, so replacing a file and finding the process already up leaves the machine + // behaving the way it did before while every check passes. + RestartOn []string `json:"restart-on,omitempty"` +} + +func (d *Daemon) Identity() string { return d.ID } +func (d *Daemon) Kind() Type { return TypeDaemon } +func (d *Daemon) Target() string { return d.Name } + +func (d *Daemon) validate(where string, _ bool) []string { + var problems []string + if d.Name == "" { + problems = append(problems, where+": a daemon needs a name, which is what its unit is called") + } + if strings.ContainsAny(d.Name, "/ \t") { + // It becomes a unit name and a file on disk. A name with a separator in it would write + // somewhere nobody meant. + problems = append(problems, where+": a daemon's name becomes a unit name, so it cannot "+ + "contain a path separator or a space") + } + if d.Source == "" { + problems = append(problems, where+": a daemon needs somewhere to fetch its bundle from") + } + if !strings.HasPrefix(d.Digest, "sha256:") || len(d.Digest) != len("sha256:")+64 { + // The same rule an archive follows, and for the same reason: this crosses a network the + // mesh does not control, and a reference that can be made to point elsewhere is not one. + problems = append(problems, where+ + ": a daemon's bundle is pinned by digest, as sha256:<64 hex characters>") + } + if len(d.Run) == 0 { + problems = append(problems, where+": a daemon needs to say what to run") + } + for _, part := range d.Run { + if part == "" { + problems = append(problems, where+": a daemon's command has an empty element") + break + } + } + return problems +} + // Service is a unit the host puts into a state. It does not install the unit. // // Two states, and they are orthogonal rather than one scale. A unit can be enabled and stopped @@ -654,6 +743,8 @@ func newOf(t Type) Resource { return &Archive{} case TypeAccess: return &Access{} + case TypeDaemon: + return &Daemon{} } return nil } @@ -661,8 +752,8 @@ func newOf(t Type) Resource { // Vocabulary is every kind this host speaks. func Vocabulary() []Type { return []Type{ - TypeAccess, TypeAction, TypeArchive, TypeContainer, TypeDirectory, TypeFile, TypeNetwork, - TypePackage, TypeService, TypeUser, + TypeAccess, TypeAction, TypeArchive, TypeContainer, TypeDaemon, TypeDirectory, TypeFile, + TypeNetwork, TypePackage, TypeService, TypeUser, } } diff --git a/internal/declaration/declaration_test.go b/internal/declaration/declaration_test.go index 11244bc..1f3c025 100644 --- a/internal/declaration/declaration_test.go +++ b/internal/declaration/declaration_test.go @@ -266,7 +266,7 @@ func TestAFieldTheNewTypesDoNotUseIsRefused(t *testing.T) { } } -func TestTheVocabularyIsTheEightShapesTheMeshNeeds(t *testing.T) { +func TestTheVocabularyIsTheElevenShapesTheMeshNeeds(t *testing.T) { // Six of them the bootstrap uses (novox/hq 07-the-substrate.md), and removing one is a // failing test rather than a discovery during a first-node install. // @@ -282,7 +282,7 @@ func TestTheVocabularyIsTheEightShapesTheMeshNeeds(t *testing.T) { } for _, want := range []Type{ TypeDirectory, TypeFile, TypeService, TypePackage, TypeContainer, TypeAction, - TypeUser, TypeArchive, TypeNetwork, TypeAccess, + TypeUser, TypeArchive, TypeNetwork, TypeAccess, TypeDaemon, } { if !speaks[want] { t.Errorf("the host no longer speaks %q", want) @@ -298,8 +298,19 @@ func TestTheVocabularyIsTheEightShapesTheMeshNeeds(t *testing.T) { // `access` is the tenth, and novox/hq ADR 0051 is its decision: shared, pre-existing data is // the operator's, and a module is granted use of it without owning it — a shape the host must // tell apart from a directory precisely because it must NOT create, chown or remove it. - if len(speaks) != 10 { - t.Errorf("the vocabulary is %d shapes rather than 10; every addition widens what a compromised "+ + // + // `daemon` is the eleventh, and it exists because the mechanism was leaking into every module. + // Running code of one's own meant a `container` and therefore an image; running a script meant + // a `service` and a unit somebody else had to install. One intent — run this and keep it + // running — expressed two unrelated ways, with the hosting chosen before anything could be + // declared. A daemon says what to run; the machine's own supervisor is how, and the mesh owns + // the unit because it is the mesh's own code (novox/hq 03-DESIGN/01-to-be/18-building-a-module.md). + // + // It is a full-host shape rather than a portable one: it needs a process supervisor to install + // into. It does NOT need a container runtime, which is the point — only software that + // genuinely needs isolation asks for a container. + if len(speaks) != 11 { + t.Errorf("the vocabulary is %d shapes rather than 11; every addition widens what a compromised "+ "control plane can express, so a change here is a decision: %s", len(speaks), vocabulary()) } diff --git a/internal/system/system.go b/internal/system/system.go index 0c2cb96..03c77d3 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -173,6 +173,11 @@ func everyShape() []declaration.Type { // bind mount, and a bind mount needs the container runtime a full host has. So it sits // here with the container it guards, not at the portable floor (novox/hq ADR 0051). declaration.TypeAccess, + // A daemon needs a process supervisor to install a unit into, which is what separates a + // full host from the floor. It does NOT need a container runtime, which is the point of + // it: the mesh's own code runs as a process on the machine, and only software that + // genuinely needs isolation asks for a container. + declaration.TypeDaemon, } } From f5cf9510c1d034861ff314f428e11bd7ea64a6c6 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 15 Sep 2026 10:26:31 +0200 Subject: [PATCH 02/12] One kind for the module's own code, with three modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut of this added a `daemon` for the long-running case alone. That would have meant a new vocabulary entry for each of the others — a scheduled task, a run-once migration, a health check — when they are one thing run at different cadences. That is a field, not four entries in a vocabulary where every entry widens what a compromised control plane can express. So it mirrors a container exactly, because it IS a container's twin: the same intent, hosted by the machine's own supervisor instead of a runtime. Stays up, runs once, or runs on a schedule. Tools, hooks and event consumers are not further modes. They are loaded by a tool host, which is itself a process that stays up — so the generic case already covers them, which is the test of whether it is generic. A scheduled process gets a timer and a unit that finishes; a long-running one gets a unit that is restarted when it exits. Getting that wrong either way is a second copy running continuously between fires, or a schedule that never fires. The modes are exclusive and validation says so near the author: something that runs once does not run on a schedule, and something not running between fires cannot be restarted when a file changes. A missed fire happens when the machine comes back rather than being skipped, which is the difference between a machine that was down and a schedule that quietly stopped. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- internal/apply/apply.go | 4 +- internal/apply/daemon_test.go | 82 -------------- internal/apply/{daemon.go => process.go} | 108 +++++++++++++++++-- internal/apply/process_test.go | 132 +++++++++++++++++++++++ internal/declaration/cron_test.go | 40 +++---- internal/declaration/daemon_test.go | 72 ------------- internal/declaration/declaration.go | 97 +++++++++++------ internal/declaration/declaration_test.go | 2 +- internal/declaration/process_test.go | 118 ++++++++++++++++++++ internal/system/system.go | 2 +- 10 files changed, 442 insertions(+), 215 deletions(-) delete mode 100644 internal/apply/daemon_test.go rename internal/apply/{daemon.go => process.go} (57%) create mode 100644 internal/apply/process_test.go delete mode 100644 internal/declaration/daemon_test.go create mode 100644 internal/declaration/process_test.go diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 6435005..a440d23 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -270,8 +270,8 @@ func applyOne(ctx context.Context, sys system.System, r declaration.Resource, ru return applyUser(ctx, sys, res, run) case *declaration.Archive: return applyArchive(ctx, res, previous) - case *declaration.Daemon: - return applyDaemon(ctx, res, run, changed, previous) + case *declaration.Process: + return applyProcess(ctx, res, run, changed, previous) case *declaration.Action: return applyAction(ctx, res, run) case *declaration.Network: diff --git a/internal/apply/daemon_test.go b/internal/apply/daemon_test.go deleted file mode 100644 index 2103818..0000000 --- a/internal/apply/daemon_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package apply - -import ( - "strings" - "testing" - - "github.com/novox/mesh-host/internal/declaration" -) - -func aDaemon() *declaration.Daemon { - return &declaration.Daemon{ - ID: "server", Type: declaration.TypeDaemon, Name: "greeter", - Source: "https://store.invalid/greeter/daemon", - Digest: "sha256:" + strings.Repeat("a", 64), - Run: []string{"node", "index.js"}, - Env: map[string]string{"MESH_NODE": "anchor", "A_FIRST": "1"}, - } -} - -// The unit the mesh writes says what it runs, where, and that it comes back. -func TestTheUnitRunsWhatTheDaemonSaid(t *testing.T) { - unit := unitFor(aDaemon()) - for _, want := range []string{ - "ExecStart=node index.js", - "WorkingDirectory=/var/lib/mesh/daemons/greeter", - "Restart=always", - "WantedBy=multi-user.target", - } { - if !strings.Contains(unit, want) { - t.Fatalf("the unit does not say %q:\n%s", want, unit) - } - } -} - -// **Generated whole and saying so.** Every managed file on a machine carries this, because an edit -// that survives until the next declaration and then vanishes is worse than one that is refused. -func TestTheUnitSaysItIsTheMeshs(t *testing.T) { - unit := unitFor(aDaemon()) - if !strings.HasPrefix(unit, "#") || !strings.Contains(unit, "Do not edit") { - t.Fatalf("the unit does not say it is generated:\n%s", unit) - } -} - -// **Deterministic, because the unit is half the daemon's identity.** Environment held in a map -// would be written in Go's iteration order, so every apply would see a different unit and call an -// unchanged daemon changed — restarting it on every declaration for ever. -func TestTheUnitIsTheSameEveryTime(t *testing.T) { - first := unitFor(aDaemon()) - for i := 0; i < 20; i++ { - if again := unitFor(aDaemon()); again != first { - t.Fatalf("two renderings of one daemon differ:\n%s\n---\n%s", first, again) - } - } - // And sorted, so the order is a decision rather than luck. - if strings.Index(first, "A_FIRST") > strings.Index(first, "MESH_NODE") { - t.Fatalf("environment is not in a stable order:\n%s", first) - } -} - -// **Two daemons from one bundle differing only in their command are different daemons.** Tracking -// the digest alone would call the second one unchanged and leave the first one running. -func TestADaemonsIdentityIncludesHowItIsRun(t *testing.T) { - one := aDaemon() - two := aDaemon() - two.Run = []string{"node", "other.js"} - if unitFor(one) == unitFor(two) { - t.Fatal("two daemons with different commands render one unit, so a change would be missed") - } -} - -// A daemon that runs as somebody says so, and one that does not says nothing — rather than naming -// root explicitly, which would be a claim the mesh does not need to make. -func TestADaemonRunsAsWhoItSaid(t *testing.T) { - as := aDaemon() - as.User = "greeter" - if !strings.Contains(unitFor(as), "User=greeter") { - t.Fatalf("the unit does not run as the user it named:\n%s", unitFor(as)) - } - if strings.Contains(unitFor(aDaemon()), "User=") { - t.Fatalf("a daemon that named no user had one written for it:\n%s", unitFor(aDaemon())) - } -} diff --git a/internal/apply/daemon.go b/internal/apply/process.go similarity index 57% rename from internal/apply/daemon.go rename to internal/apply/process.go index 1d7d0dc..12e833e 100644 --- a/internal/apply/daemon.go +++ b/internal/apply/process.go @@ -35,7 +35,7 @@ const daemonRoot = "/var/lib/mesh/daemons" // unitDir is where the mesh writes the units it owns. const unitDir = "/etc/systemd/system" -func applyDaemon(ctx context.Context, r *declaration.Daemon, run Runner, +func applyProcess(ctx context.Context, r *declaration.Process, run Runner, changed map[string]bool, previous store.Applied) (Outcome, error) { out := begin(r) out.Action = "unchanged" @@ -54,9 +54,9 @@ func applyDaemon(ctx context.Context, r *declaration.Daemon, run Runner, r.Source, r.Digest, got) } - // **The identity of a daemon is its bytes AND how it is run.** Two daemons from one bundle - // differing only in their command are different daemons, and a record that tracked the digest - // alone would call the second one unchanged. + // **Its identity is its bytes AND how it is run.** Two processes from one bundle differing + // only in their command are different, and a record tracking the digest alone would call the + // second one unchanged. want := got + " " + unitFor(r) at := filepath.Join(daemonRoot, r.Name) @@ -105,6 +105,23 @@ func applyDaemon(ctx context.Context, r *declaration.Daemon, run Runner, return out, err } + // **A step is run to completion, not installed.** What follows it is gated on it finishing, + // so the machine is not asked to start something that needed a migration that did not happen. + // Nothing is left behind to ask afterwards: the record that it ran is the digest, which is why + // the identity above includes the command. + if r.RunOnce { + if _, err := run(ctx, r.Run[0], r.Run[1:]...); err != nil { + return out, fmt.Errorf("the %s step did not complete: %w", r.Name, err) + } + out.Action = "created" + if previous.Wrote != "" { + out.Action = "updated" + } + out.Detail = fmt.Sprintf("%d file(s), step completed", written) + out.wrote = want + return out, nil + } + unit := filepath.Join(unitDir, r.Name+".service") if err := os.WriteFile(unit, []byte(unitFor(r)), 0o644); err != nil { return out, err @@ -114,6 +131,35 @@ func applyDaemon(ctx context.Context, r *declaration.Daemon, run Runner, } // Enabled and restarted, in that order: enabled so it survives a reboot, restarted rather than // started because this path is also how a new version arrives and the old one is still running. + // **Scheduled means a timer, not a service that stays up.** The unit above is written either + // way and describes what to run; what differs is whether the machine is asked to keep it + // running or to start it when the timer says so. + if r.Schedule != "" { + timer := filepath.Join(unitDir, r.Name+".timer") + if err := os.WriteFile(timer, []byte(timerFor(r)), 0o644); err != nil { + return out, err + } + if _, err := run(ctx, "systemctl", "daemon-reload"); err != nil { + return out, err + } + // The timer is enabled and started; the service is neither. Enabling the service too + // would have it run at boot as well as on its cadence, which is a second schedule nobody + // asked for. + if _, err := run(ctx, "systemctl", "enable", r.Name+".timer"); err != nil { + return out, err + } + if _, err := run(ctx, "systemctl", "restart", r.Name+".timer"); err != nil { + return out, fmt.Errorf("%s was installed and its timer would not start: %w", r.Name, err) + } + out.Action = "updated" + if previous.Wrote == "" { + out.Action = "created" + } + out.Detail = fmt.Sprintf("%d file(s), scheduled as %s.timer", written, r.Name) + out.wrote = want + return out, nil + } + if _, err := run(ctx, "systemctl", "enable", r.Name+".service"); err != nil { return out, err } @@ -141,7 +187,7 @@ func applyDaemon(ctx context.Context, r *declaration.Daemon, run Runner, // // Deterministic — environment sorted — because this string is half the daemon's identity, and a // map iterated in Go's order would make every apply look like a change. -func unitFor(r *declaration.Daemon) string { +func unitFor(r *declaration.Process) string { var b strings.Builder b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever the\n") b.WriteString("# declaration changes, and an edit would survive until then and vanish.\n") @@ -163,10 +209,58 @@ func unitFor(r *declaration.Daemon) string { fmt.Fprintf(&b, "User=%s\n", r.User) } fmt.Fprintf(&b, "ExecStart=%s\n", strings.Join(r.Run, " ")) - // Restarted when it exits, because a daemon that stops is not a daemon. Delayed, so a process - // that fails at once does not spin the machine. + if r.Schedule != "" { + // Started by its timer and expected to finish. Restarting it would have it run + // continuously between fires, which is the opposite of a schedule. + b.WriteString("Type=oneshot\n") + b.WriteString("\n") + return strings.Replace(b.String(), "Type=simple\n", "", 1) + } + // Restarted when it exits, because something that stops is not something that stays up. + // Delayed, so a process that fails at once does not spin the machine. b.WriteString("Restart=always\nRestartSec=5\n\n") b.WriteString("[Install]\nWantedBy=multi-user.target\n") return b.String() } + +// timerFor is the cadence a scheduled process runs on. +// +// **The mesh's cron expression, handed to the machine's own timer.** The host already parses and +// evaluates five-field cron (ADR 0053) for a scheduled container; a machine with a supervisor can +// be told the cadence directly rather than have the host wake up and decide. +func timerFor(r *declaration.Process) string { + var b strings.Builder + b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever the\n") + b.WriteString("# declaration changes, and an edit would survive until then and vanish.\n") + b.WriteString("[Unit]\n") + fmt.Fprintf(&b, "Description=%s, on a schedule the mesh set\n\n", r.Name) + b.WriteString("[Timer]\n") + fmt.Fprintf(&b, "OnCalendar=%s\n", calendarFor(r.Schedule)) + // A fire missed because the machine was off happens when it comes back, rather than being + // skipped silently — which is the difference between a machine that was down and a schedule + // that quietly stopped. + b.WriteString("Persistent=true\n\n") + b.WriteString("[Install]\nWantedBy=timers.target\n") + return b.String() +} + +// calendarFor turns five-field cron into what a systemd timer reads. +// +// minute hour day-of-month month day-of-week -> DayOfWeek Year-Month-Day Hour:Minute:Second +func calendarFor(cron string) string { + fields := strings.Fields(cron) + if len(fields) != 5 { + // Refused at validation, so this is unreachable — and returning something that would fire + // constantly is worse than returning something that never does. + return "*-*-* 00:00:00" + } + minute, hour, dom, month, dow := fields[0], fields[1], fields[2], fields[3], fields[4] + day := dow + if dow == "*" { + day = "" + } else { + day += " " + } + return fmt.Sprintf("%s*-%s-%s %s:%s:00", day, month, dom, hour, minute) +} diff --git a/internal/apply/process_test.go b/internal/apply/process_test.go new file mode 100644 index 0000000..1fd0826 --- /dev/null +++ b/internal/apply/process_test.go @@ -0,0 +1,132 @@ +package apply + +import ( + "strings" + "testing" + + "github.com/novox/mesh-host/internal/declaration" +) + +func aProcess() *declaration.Process { + return &declaration.Process{ + ID: "server", Type: declaration.TypeProcess, Name: "greeter", + Source: "https://store.invalid/greeter/daemon", + Digest: "sha256:" + strings.Repeat("a", 64), + Run: []string{"node", "index.js"}, + Env: map[string]string{"MESH_NODE": "anchor", "A_FIRST": "1"}, + } +} + +// The unit the mesh writes says what it runs, where, and that it comes back. +func TestTheUnitRunsWhatTheDaemonSaid(t *testing.T) { + unit := unitFor(aProcess()) + for _, want := range []string{ + "ExecStart=node index.js", + "WorkingDirectory=/var/lib/mesh/daemons/greeter", + "Restart=always", + "WantedBy=multi-user.target", + } { + if !strings.Contains(unit, want) { + t.Fatalf("the unit does not say %q:\n%s", want, unit) + } + } +} + +// **Generated whole and saying so.** Every managed file on a machine carries this, because an edit +// that survives until the next declaration and then vanishes is worse than one that is refused. +func TestTheUnitSaysItIsTheMeshs(t *testing.T) { + unit := unitFor(aProcess()) + if !strings.HasPrefix(unit, "#") || !strings.Contains(unit, "Do not edit") { + t.Fatalf("the unit does not say it is generated:\n%s", unit) + } +} + +// **Deterministic, because the unit is half the daemon's identity.** Environment held in a map +// would be written in Go's iteration order, so every apply would see a different unit and call an +// unchanged daemon changed — restarting it on every declaration for ever. +func TestTheUnitIsTheSameEveryTime(t *testing.T) { + first := unitFor(aProcess()) + for i := 0; i < 20; i++ { + if again := unitFor(aProcess()); again != first { + t.Fatalf("two renderings of one daemon differ:\n%s\n---\n%s", first, again) + } + } + // And sorted, so the order is a decision rather than luck. + if strings.Index(first, "A_FIRST") > strings.Index(first, "MESH_NODE") { + t.Fatalf("environment is not in a stable order:\n%s", first) + } +} + +// **Two daemons from one bundle differing only in their command are different daemons.** Tracking +// the digest alone would call the second one unchanged and leave the first one running. +func TestAProcesssIdentityIncludesHowItIsRun(t *testing.T) { + one := aProcess() + two := aProcess() + two.Run = []string{"node", "other.js"} + if unitFor(one) == unitFor(two) { + t.Fatal("two daemons with different commands render one unit, so a change would be missed") + } +} + +// A process that runs as somebody says so, and one that does not says nothing — rather than naming +// root explicitly, which would be a claim the mesh does not need to make. +func TestAProcessRunsAsWhoItSaid(t *testing.T) { + as := aProcess() + as.User = "greeter" + if !strings.Contains(unitFor(as), "User=greeter") { + t.Fatalf("the unit does not run as the user it named:\n%s", unitFor(as)) + } + if strings.Contains(unitFor(aProcess()), "User=") { + t.Fatalf("a process that named no user had one written for it:\n%s", unitFor(aProcess())) + } +} + +// **Three modes, one kind.** A scheduled process is a timer plus a unit that finishes, not a unit +// that stays up — and the difference has to be in what is written, or a schedule becomes a second +// copy running continuously between fires. +func TestAScheduledProcessRunsOnItsCadenceRatherThanContinuously(t *testing.T) { + every := aProcess() + every.Schedule = "0 3 * * *" + + unit := unitFor(every) + if strings.Contains(unit, "Restart=always") { + t.Fatalf("a scheduled process is restarted whenever it exits, so it never stops:\n%s", unit) + } + if !strings.Contains(unit, "Type=oneshot") { + t.Fatalf("a scheduled process is not a step that finishes:\n%s", unit) + } + + timer := timerFor(every) + if !strings.Contains(timer, "OnCalendar=") { + t.Fatalf("a scheduled process has no cadence:\n%s", timer) + } + // A fire missed while the machine was off happens when it returns, rather than being skipped — + // the difference between a machine that was down and a schedule that quietly stopped. + if !strings.Contains(timer, "Persistent=true") { + t.Fatalf("a missed fire is skipped silently:\n%s", timer) + } +} + +// Five-field cron becomes what a timer reads, rather than the host waking to decide. +func TestACronBecomesATimersCalendar(t *testing.T) { + for cron, want := range map[string]string{ + "0 3 * * *": "*-*-* 3:0:00", + "30 4 1 * *": "*-*-1 4:30:00", + "0 0 * * mon": "mon *-*-* 0:0:00", + } { + if got := calendarFor(cron); got != want { + t.Fatalf("%q became %q rather than %q", cron, got, want) + } + } +} + +// And the long-running mode is unchanged by any of it: it stays up and comes back. +func TestAProcessThatStaysUpIsStillRestartedWhenItExits(t *testing.T) { + unit := unitFor(aProcess()) + if !strings.Contains(unit, "Restart=always") { + t.Fatalf("a process that should stay up is not restarted when it exits:\n%s", unit) + } + if strings.Contains(unit, "Type=oneshot") { + t.Fatalf("a process that should stay up is declared a step:\n%s", unit) + } +} diff --git a/internal/declaration/cron_test.go b/internal/declaration/cron_test.go index 881852e..51e3dc0 100644 --- a/internal/declaration/cron_test.go +++ b/internal/declaration/cron_test.go @@ -11,19 +11,19 @@ import ( func TestParseCronRefusesMalformed(t *testing.T) { for _, expr := range []string{ - "", // nothing - "* * * *", // four fields - "* * * * * *", // six fields - "60 * * * *", // minute out of range - "* 24 * * *", // hour out of range - "* * 0 * *", // day-of-month below 1 - "* * 32 * *", // day-of-month above 31 - "* * * 13 *", // month out of range - "* * * * 8", // day-of-week above 7 - "a * * * *", // not a number - "*/0 * * * *", // zero step - "5-1 * * * *", // inverted range - "1,,2 * * * *", // empty element + "", // nothing + "* * * *", // four fields + "* * * * * *", // six fields + "60 * * * *", // minute out of range + "* 24 * * *", // hour out of range + "* * 0 * *", // day-of-month below 1 + "* * 32 * *", // day-of-month above 31 + "* * * 13 *", // month out of range + "* * * * 8", // day-of-week above 7 + "a * * * *", // not a number + "*/0 * * * *", // zero step + "5-1 * * * *", // inverted range + "1,,2 * * * *", // empty element } { if _, err := ParseCron(expr); err == nil { t.Errorf("a malformed cron %q was accepted", expr) @@ -33,13 +33,13 @@ func TestParseCronRefusesMalformed(t *testing.T) { func TestParseCronAcceptsTheOrdinaryForms(t *testing.T) { for _, expr := range []string{ - "* * * * *", // every minute - "0 3 * * *", // 03:00 daily - "*/15 * * * *", // every 15 minutes - "0 0 1 1 *", // new year - "0 9-17 * * 1-5", // business hours, weekdays - "0 0 * * 7", // Sunday as 7 - "0,30 * * * *", // twice an hour + "* * * * *", // every minute + "0 3 * * *", // 03:00 daily + "*/15 * * * *", // every 15 minutes + "0 0 1 1 *", // new year + "0 9-17 * * 1-5", // business hours, weekdays + "0 0 * * 7", // Sunday as 7 + "0,30 * * * *", // twice an hour } { if _, err := ParseCron(expr); err != nil { t.Errorf("a valid cron %q was refused: %v", expr, err) diff --git a/internal/declaration/daemon_test.go b/internal/declaration/daemon_test.go deleted file mode 100644 index d741753..0000000 --- a/internal/declaration/daemon_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package declaration - -import ( - "strings" - "testing" -) - -func aDaemon() *Daemon { - return &Daemon{ - ID: "server", Type: TypeDaemon, Name: "greeter", - Source: "https://store.invalid/greeter/daemon", - Digest: "sha256:" + strings.Repeat("a", 64), - Run: []string{"node", "index.js"}, - } -} - -// A daemon is part of the vocabulary, or a declaration carrying one is refused whole. -func TestADaemonIsSomethingTheHostSpeaks(t *testing.T) { - var found bool - for _, kind := range Vocabulary() { - if kind == TypeDaemon { - found = true - } - } - if !found { - t.Fatal("a daemon cannot be declared, so a module that declares one is refused") - } - if newOf(TypeDaemon) == nil { - t.Fatal("the decoder has no daemon, so one would be refused as an unknown kind") - } -} - -// **Pinned by digest, like everything else that crosses a network.** A bundle fetched by a -// reference somebody can repoint is not pinned, and it is the one thing on a machine that would -// then be running code nobody reviewed. -func TestADaemonsBundleMustBePinned(t *testing.T) { - for _, bad := range []string{"", "latest", "sha256:short", strings.Repeat("a", 64)} { - d := aDaemon() - d.Digest = bad - if problems := d.validate("a daemon", false); len(problems) == 0 { - t.Fatalf("a daemon pinned by %q was accepted", bad) - } - } -} - -// What to run is named, never inferred. Guessing an entrypoint from which files are present makes -// a daemon change what it runs when somebody adds a file. -func TestADaemonMustSayWhatToRun(t *testing.T) { - d := aDaemon() - d.Run = nil - if problems := d.validate("a daemon", false); len(problems) == 0 { - t.Fatal("a daemon with no command was accepted") - } -} - -// Its name becomes a unit name and a path, so a separator in it would write somewhere nobody meant. -func TestADaemonsNameCannotEscapeItsUnit(t *testing.T) { - for _, bad := range []string{"", "../escape", "two words", "a/b"} { - d := aDaemon() - d.Name = bad - if problems := d.validate("a daemon", false); len(problems) == 0 { - t.Fatalf("a daemon called %q was accepted", bad) - } - } -} - -// And a well-formed one is accepted, or the tests above prove only that everything is refused. -func TestAWellFormedDaemonIsAccepted(t *testing.T) { - if problems := aDaemon().validate("a daemon", false); len(problems) != 0 { - t.Fatalf("a well-formed daemon was refused: %v", problems) - } -} diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index 26c94a3..2c155eb 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -60,19 +60,24 @@ const ( // access is ordinary, because none of them owns it. TypeAccess Type = "access" - // TypeDaemon is a long-running process the mesh keeps running, named by what it runs rather - // than by how it is hosted. + // TypeProcess is the module's own code, run on the machine, in one of three modes. // // **The intent, not the mechanism.** Until this, an author decided the hosting before they // could declare anything: code of their own meant a `container` built from an image, a script - // meant a `service` and a unit somebody else had to install. Same intent — run this and keep - // it running — expressed two unrelated ways, and the choice baked into which kind was picked. + // meant a `service` and a unit somebody else had to install. Same intent — run this — with + // the choice baked into which kind was picked. // - // A daemon names an artifact and what to run. The mesh unpacks the artifact where it keeps - // such things, writes the unit, and puts it in the state asked for. One module may declare - // several, in different languages, because a module is one piece of software and not one - // process (novox/hq ADR 0040). - TypeDaemon Type = "daemon" + // **And three modes rather than three kinds**, exactly as a container has. A first draft of + // this added a `daemon` for the long-running case alone, which would have meant a new kind for + // each of the others — a scheduled task, a run-once migration, a health check. They are one + // thing run at different cadences, and that is a field, not a vocabulary entry. Every addition + // to this vocabulary widens what a compromised control plane can express. + // + // What a module declares is a bundle and a command. The mesh unpacks the bundle where it keeps + // such things and runs it — as a unit that stays up, as a step that must finish, or on a + // cadence. Tools, hooks and event consumers are not separate modes: they are loaded by a tool + // host, which is itself a process that stays up. + TypeProcess Type = "process" ) // Resource is one thing that should be true of the machine. @@ -407,17 +412,22 @@ func (a *Archive) validate(where string, _ bool) []string { return problems } -// Daemon is a long-running process the mesh installs, keeps running, and owns the unit for. +// Process is the module's own code, run on the machine, in one of three modes. // // The difference from Service is who owns the unit: a Service puts an EXISTING unit into a state -// and deliberately does not install one, which is right for software that ships its own. A Daemon -// is the mesh's own code — a bundle it built — so there is no unit until the mesh writes it, and +// and deliberately does not install one, which is right for software that ships its own. This is +// the mesh's own code — a bundle it built — so there is no unit until the mesh writes it, and // nothing else will. // -// The difference from Container is the hosting, and a module should not have to choose: what a -// daemon says is what to run, and the machine's own process supervisor is how. A module whose code -// genuinely needs a container's isolation declares a container and says so. -type Daemon struct { +// The difference from Container is the hosting, and a module should not have to choose: what this +// says is what to run, and the machine's own supervisor is how. Code that genuinely needs a +// container's isolation declares a container and says so. +// +// **Three modes, matching a container's**, because they are the same thing at different cadences: +// stays up, runs once, runs on a schedule. A module's scheduled task, its run-once migration, its +// health check and its tool host are all this — and each being its own resource kind would be four +// entries in a vocabulary where every entry widens what a compromised control plane can express. +type Process struct { ID string `json:"id"` Type Type `json:"type"` // Name is what the unit is called, and what an operator will see in the process table. @@ -444,41 +454,68 @@ type Daemon struct { // configuration, so replacing a file and finding the process already up leaves the machine // behaving the way it did before while every check passes. RestartOn []string `json:"restart-on,omitempty"` + + // RunOnce marks code the host runs to completion rather than leaves running: a migration, a + // seed, a first-boot step. What follows it is gated on it finishing, because a step that did + // not make the machine ready must not be followed by the thing that needed it. + RunOnce bool `json:"run-once,omitempty"` + + // Schedule runs it on a cadence — a five-field cron expression (novox/hq ADR 0053). The + // recurring twin of RunOnce: the same code, run again rather than left running. + // + // Exclusive with RunOnce and with RestartOn, for the same reason a container's is: something + // that runs once does not run on a schedule, and something that is not running cannot be + // restarted when a file changes. + Schedule string `json:"schedule,omitempty"` } -func (d *Daemon) Identity() string { return d.ID } -func (d *Daemon) Kind() Type { return TypeDaemon } -func (d *Daemon) Target() string { return d.Name } +func (d *Process) Identity() string { return d.ID } +func (d *Process) Kind() Type { return TypeProcess } +func (d *Process) Target() string { return d.Name } -func (d *Daemon) validate(where string, _ bool) []string { +func (d *Process) validate(where string, _ bool) []string { var problems []string if d.Name == "" { - problems = append(problems, where+": a daemon needs a name, which is what its unit is called") + problems = append(problems, where+": a process needs a name, which is what its unit is called") } if strings.ContainsAny(d.Name, "/ \t") { // It becomes a unit name and a file on disk. A name with a separator in it would write // somewhere nobody meant. - problems = append(problems, where+": a daemon's name becomes a unit name, so it cannot "+ + problems = append(problems, where+": a process name becomes a unit name, so it cannot "+ "contain a path separator or a space") } if d.Source == "" { - problems = append(problems, where+": a daemon needs somewhere to fetch its bundle from") + problems = append(problems, where+": a process needs somewhere to fetch its bundle from") } if !strings.HasPrefix(d.Digest, "sha256:") || len(d.Digest) != len("sha256:")+64 { // The same rule an archive follows, and for the same reason: this crosses a network the // mesh does not control, and a reference that can be made to point elsewhere is not one. problems = append(problems, where+ - ": a daemon's bundle is pinned by digest, as sha256:<64 hex characters>") + ": a process bundle is pinned by digest, as sha256:<64 hex characters>") } if len(d.Run) == 0 { - problems = append(problems, where+": a daemon needs to say what to run") + problems = append(problems, where+": a process needs to say what to run") } for _, part := range d.Run { if part == "" { - problems = append(problems, where+": a daemon's command has an empty element") + problems = append(problems, where+": a process command has an empty element") break } } + if d.Schedule != "" { + if d.RunOnce { + problems = append(problems, where+ + ": a process runs once or on a schedule, not both") + } + if len(d.RestartOn) > 0 { + problems = append(problems, where+ + ": a scheduled process is not running between its fires, so there is nothing to "+ + "restart when something it reads changes") + } + if _, err := ParseCron(d.Schedule); err != nil { + problems = append(problems, where+": "+err.Error()) + } + } return problems } @@ -743,8 +780,8 @@ func newOf(t Type) Resource { return &Archive{} case TypeAccess: return &Access{} - case TypeDaemon: - return &Daemon{} + case TypeProcess: + return &Process{} } return nil } @@ -752,8 +789,8 @@ func newOf(t Type) Resource { // Vocabulary is every kind this host speaks. func Vocabulary() []Type { return []Type{ - TypeAccess, TypeAction, TypeArchive, TypeContainer, TypeDaemon, TypeDirectory, TypeFile, - TypeNetwork, TypePackage, TypeService, TypeUser, + TypeAccess, TypeAction, TypeArchive, TypeContainer, TypeDirectory, TypeFile, + TypeNetwork, TypePackage, TypeProcess, TypeService, TypeUser, } } diff --git a/internal/declaration/declaration_test.go b/internal/declaration/declaration_test.go index 1f3c025..9a0c27a 100644 --- a/internal/declaration/declaration_test.go +++ b/internal/declaration/declaration_test.go @@ -282,7 +282,7 @@ func TestTheVocabularyIsTheElevenShapesTheMeshNeeds(t *testing.T) { } for _, want := range []Type{ TypeDirectory, TypeFile, TypeService, TypePackage, TypeContainer, TypeAction, - TypeUser, TypeArchive, TypeNetwork, TypeAccess, TypeDaemon, + TypeUser, TypeArchive, TypeNetwork, TypeAccess, TypeProcess, } { if !speaks[want] { t.Errorf("the host no longer speaks %q", want) diff --git a/internal/declaration/process_test.go b/internal/declaration/process_test.go new file mode 100644 index 0000000..a8d6c2b --- /dev/null +++ b/internal/declaration/process_test.go @@ -0,0 +1,118 @@ +package declaration + +import ( + "strings" + "testing" +) + +func aProcess() *Process { + return &Process{ + ID: "server", Type: TypeProcess, Name: "greeter", + Source: "https://store.invalid/greeter/daemon", + Digest: "sha256:" + strings.Repeat("a", 64), + Run: []string{"node", "index.js"}, + } +} + +// A process is part of the vocabulary, or a declaration carrying one is refused whole. +func TestAProcessIsSomethingTheHostSpeaks(t *testing.T) { + var found bool + for _, kind := range Vocabulary() { + if kind == TypeProcess { + found = true + } + } + if !found { + t.Fatal("a process cannot be declared, so a module that declares one is refused") + } + if newOf(TypeProcess) == nil { + t.Fatal("the decoder has no daemon, so one would be refused as an unknown kind") + } +} + +// **Pinned by digest, like everything else that crosses a network.** A bundle fetched by a +// reference somebody can repoint is not pinned, and it is the one thing on a machine that would +// then be running code nobody reviewed. +func TestAProcesssBundleMustBePinned(t *testing.T) { + for _, bad := range []string{"", "latest", "sha256:short", strings.Repeat("a", 64)} { + d := aProcess() + d.Digest = bad + if problems := d.validate("a process", false); len(problems) == 0 { + t.Fatalf("a process pinned by %q was accepted", bad) + } + } +} + +// What to run is named, never inferred. Guessing an entrypoint from which files are present makes +// a process change what it runs when somebody adds a file. +func TestAProcessMustSayWhatToRun(t *testing.T) { + d := aProcess() + d.Run = nil + if problems := d.validate("a process", false); len(problems) == 0 { + t.Fatal("a process with no command was accepted") + } +} + +// Its name becomes a unit name and a path, so a separator in it would write somewhere nobody meant. +func TestAProcesssNameCannotEscapeItsUnit(t *testing.T) { + for _, bad := range []string{"", "../escape", "two words", "a/b"} { + d := aProcess() + d.Name = bad + if problems := d.validate("a process", false); len(problems) == 0 { + t.Fatalf("a process called %q was accepted", bad) + } + } +} + +// And a well-formed one is accepted, or the tests above prove only that everything is refused. +func TestAWellFormedDaemonIsAccepted(t *testing.T) { + if problems := aProcess().validate("a process", false); len(problems) != 0 { + t.Fatalf("a well-formed daemon was refused: %v", problems) + } +} + +// **The modes are exclusive, and saying so is the point of having one kind.** Something that runs +// once does not run on a schedule; something not running between fires cannot be restarted when a +// file changes. A container's modes carry the same rule, and this is the same rule because it is +// the same thing hosted differently. +func TestTheModesAreExclusive(t *testing.T) { + both := aProcess() + both.RunOnce = true + both.Schedule = "0 3 * * *" + if problems := both.validate("a process", false); len(problems) == 0 { + t.Fatal("a process that runs once and on a schedule was accepted") + } + + watching := aProcess() + watching.Schedule = "0 3 * * *" + watching.RestartOn = []string{"some-file"} + if problems := watching.validate("a process", false); len(problems) == 0 { + t.Fatal("a scheduled process was given something to restart on, and it is never running") + } +} + +// A cadence that is not a cadence is refused near its author, rather than by a machine at the far +// end of a declaration. +func TestAScheduleMustBeACadence(t *testing.T) { + for _, bad := range []string{"often", "0 3 * *", "99 3 * * *"} { + p := aProcess() + p.Schedule = bad + if problems := p.validate("a process", false); len(problems) == 0 { + t.Fatalf("a process scheduled %q was accepted", bad) + } + } +} + +// And each mode on its own is accepted, or the tests above prove only that everything is refused. +func TestEachModeOnItsOwnIsAccepted(t *testing.T) { + once := aProcess() + once.RunOnce = true + if problems := once.validate("a process", false); len(problems) != 0 { + t.Fatalf("a step was refused: %v", problems) + } + every := aProcess() + every.Schedule = "0 3 * * *" + if problems := every.validate("a process", false); len(problems) != 0 { + t.Fatalf("a scheduled process was refused: %v", problems) + } +} diff --git a/internal/system/system.go b/internal/system/system.go index 03c77d3..3f928e6 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -177,7 +177,7 @@ func everyShape() []declaration.Type { // full host from the floor. It does NOT need a container runtime, which is the point of // it: the mesh's own code runs as a process on the machine, and only software that // genuinely needs isolation asks for a container. - declaration.TypeDaemon, + declaration.TypeProcess, } } From de5160de4a199d0a9637da93be2b4614f44c395f Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 15 Sep 2026 12:40:36 +0200 Subject: [PATCH 03/12] A unit file reinterprets an environment value; a container does not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by being asked whether processes and containers handle environment the same way. They do not, and the difference is not cosmetic. Docker passes --env through literally. A unit file reads three things out of a value that nothing else does, and a module's environment routinely contains all three because a generated password is arbitrary bytes: - % begins a specifier. %H is the hostname. A password containing one is silently replaced, and it fails later as an authentication error nobody can explain by reading the declaration. - whitespace separates assignments. Unquoted, K=a b sets K to "a" and reads "b" as another assignment. - a newline ends the line, and what follows is read as a unit DIRECTIVE. The first two are escaped: quoted, with quotes and backslashes escaped and percent doubled. The third cannot be — a unit's environment has no way to carry a line break — so it is refused in validation, near whoever wrote it. Without that, an environment value could write ExecStart= and have the machine run something nobody declared. Ordinary awkward values stay accepted, because refusing those too would leave a module unable to hold a generated password. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- internal/apply/process.go | 27 +++++++++++++++++- internal/apply/process_test.go | 33 ++++++++++++++++++++++ internal/declaration/declaration.go | 18 ++++++++++++ internal/declaration/process_test.go | 42 ++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 1 deletion(-) diff --git a/internal/apply/process.go b/internal/apply/process.go index 12e833e..686b7f1 100644 --- a/internal/apply/process.go +++ b/internal/apply/process.go @@ -203,7 +203,7 @@ func unitFor(r *declaration.Process) string { fmt.Fprintf(&b, "EnvironmentFile=%s\n", file) } for _, key := range sortedKeys(r.Env) { - fmt.Fprintf(&b, "Environment=%s=%s\n", key, r.Env[key]) + fmt.Fprintf(&b, "Environment=%s\n", unitValue(key, r.Env[key])) } if r.User != "" { fmt.Fprintf(&b, "User=%s\n", r.User) @@ -264,3 +264,28 @@ func calendarFor(cron string) string { } return fmt.Sprintf("%s*-%s-%s %s:%s:00", day, month, dom, hour, minute) } + +// unitValue renders one environment assignment so a unit file means what the declaration said. +// +// **Three things a unit file does to a value that nothing else does**, and a module's environment +// routinely contains all three — a generated password is arbitrary bytes. +// +// - `%` begins a specifier. `%H` is the hostname, `%i` the instance. A password containing one +// is silently replaced by something else, and the failure is an authentication error nobody +// can explain by looking at the declaration. +// - whitespace separates assignments. `Environment=K=a b` sets K to "a" and then tries to read +// "b" as another assignment. +// - a newline ends the line. What follows it is read as a unit DIRECTIVE, so a value carrying +// one could write ExecStart= and have the machine run something nobody declared. +// +// Quoted, with quotes and backslashes escaped and percent doubled. A container needs none of this +// because `--env` is passed through literally, which is why this had to be found here rather than +// noticed in both. +func unitValue(key, value string) string { + escaped := strings.NewReplacer( + `\`, `\\`, + `"`, `\"`, + "%", "%%", + ).Replace(value) + return `"` + key + "=" + escaped + `"` +} diff --git a/internal/apply/process_test.go b/internal/apply/process_test.go index 1fd0826..3d38e22 100644 --- a/internal/apply/process_test.go +++ b/internal/apply/process_test.go @@ -130,3 +130,36 @@ func TestAProcessThatStaysUpIsStillRestartedWhenItExits(t *testing.T) { t.Fatalf("a process that should stay up is declared a step:\n%s", unit) } } + +// **A unit file reinterprets a value in three ways nothing else does**, and a module's environment +// routinely contains all three — a generated password is arbitrary bytes. +func TestAnEnvironmentValueMeansWhatTheDeclarationSaid(t *testing.T) { + // A percent begins a specifier: %H is the hostname. A password containing one would be + // silently replaced, failing as an authentication error nobody can explain from the + // declaration. + percent := aProcess() + percent.Env = map[string]string{"PASSWORD": "a%Hb"} + if !strings.Contains(unitFor(percent), "%%H") { + t.Fatalf("a percent was left as a systemd specifier:\n%s", unitFor(percent)) + } + + // Whitespace separates assignments: unquoted, K=a b sets K to "a" and reads "b" as another. + spaced := aProcess() + spaced.Env = map[string]string{"GREETING": "hello there"} + if !strings.Contains(unitFor(spaced), `"GREETING=hello there"`) { + t.Fatalf("a value with a space was not quoted:\n%s", unitFor(spaced)) + } + + // A quote would end the quoting early, and what follows would be read as unit syntax. + quoted := aProcess() + quoted.Env = map[string]string{"TOKEN": `a"b`} + line := "" + for _, l := range strings.Split(unitFor(quoted), "\n") { + if strings.HasPrefix(l, "Environment=") { + line = l + } + } + if !strings.Contains(line, `\"`) { + t.Fatalf("a quote was not escaped, so the value ends early: %s", line) + } +} diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index 2c155eb..909e540 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -502,6 +502,24 @@ func (d *Process) validate(where string, _ bool) []string { break } } + // **A newline cannot be represented in a unit's environment, so it is refused rather than + // mangled.** Everything else a unit file reinterprets — a percent specifier, whitespace + // splitting assignments, a quote ending one early — can be escaped. A newline cannot: it ends + // the line, and what follows is read as a unit DIRECTIVE. A value carrying one could write + // ExecStart= and have the machine run something nobody declared. + // + // Refused here, near whoever wrote it, rather than at the far end of a declaration. + for key, value := range d.Env { + if strings.ContainsAny(value, "\n\r") { + problems = append(problems, fmt.Sprintf( + "%s: the value of %s contains a line break, which cannot be written into a unit's "+ + "environment — what followed it would be read as a unit directive", where, key)) + } + if key == "" { + problems = append(problems, where+": an environment value with no name") + } + } + if d.Schedule != "" { if d.RunOnce { problems = append(problems, where+ diff --git a/internal/declaration/process_test.go b/internal/declaration/process_test.go index a8d6c2b..c52519f 100644 --- a/internal/declaration/process_test.go +++ b/internal/declaration/process_test.go @@ -116,3 +116,45 @@ func TestEachModeOnItsOwnIsAccepted(t *testing.T) { t.Fatalf("a scheduled process was refused: %v", problems) } } + +// **The one that cannot be escaped, only refused.** +// +// Everything else a unit file reinterprets can be escaped: a percent specifier doubled, whitespace +// quoted, a quote backslashed. A newline cannot — it ends the line, and what follows is read as a +// unit DIRECTIVE. A value carrying one could write ExecStart= and have the machine run something +// nobody declared. +// +// So it is refused here, near whoever wrote it, rather than rendered into a unit at the far end of +// a declaration. +func TestAnEnvironmentValueCannotCarryALineBreak(t *testing.T) { + for _, bad := range []string{ + "safe\nExecStart=/usr/bin/whatever", + "carriage\rreturn", + } { + p := aProcess() + p.Env = map[string]string{"X": bad} + problems := p.validate("a process", false) + if len(problems) == 0 { + t.Fatalf("a value containing %q was accepted", bad) + } + var said bool + for _, problem := range problems { + if strings.Contains(problem, "line break") { + said = true + } + } + if !said { + t.Fatalf("refused for some other reason, which would stop being true: %v", problems) + } + } +} + +// And ordinary awkward values are accepted, because escaping is what handles those — refusing them +// too would make a module unable to hold a generated password. +func TestOrdinaryAwkwardValuesAreAccepted(t *testing.T) { + p := aProcess() + p.Env = map[string]string{"PASSWORD": `a%H b"c\d`} + if problems := p.validate("a process", false); len(problems) != 0 { + t.Fatalf("a password containing the characters passwords contain was refused: %v", problems) + } +} From 7a223464e5f7938180ffc2ad1fc07c7ee47340bd Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 15 Sep 2026 20:51:00 +0200 Subject: [PATCH 04/12] Genesis installs distribution, which is what the software is called The module that provides artifact-store runs Distribution, the OCI reference implementation. It was called registry, which named neither the software nor the provision. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- internal/bootstrap/registry.go | 7 ++++++- internal/bootstrap/registry_test.go | 6 +++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/internal/bootstrap/registry.go b/internal/bootstrap/registry.go index 637d9e7..2578473 100644 --- a/internal/bootstrap/registry.go +++ b/internal/bootstrap/registry.go @@ -10,7 +10,12 @@ import ( ) // RegistryModule is the module that provides the mesh's artifact store. -const RegistryModule = "registry" +// +// **Named for the software, not the job.** The job is `artifact-store`, which is the provision this +// module offers; the software is Distribution, the OCI reference implementation. Calling the module +// `registry` named neither — and promised that any registry could sit there, which is the false +// genericity the naming rule forbids (novox/hq ADR 0040). +const RegistryModule = "distribution" // registryResource is the id of the resource in that module's manifest that runs the registry. // What the container is CALLED is read from the manifest rather than assumed, because the name is diff --git a/internal/bootstrap/registry_test.go b/internal/bootstrap/registry_test.go index 52f923d..effae95 100644 --- a/internal/bootstrap/registry_test.go +++ b/internal/bootstrap/registry_test.go @@ -35,7 +35,7 @@ func catalogueWith(t *testing.T, module, manifest string) string { } const upstreamRegistryManifest = `{ - "module": "registry", + "module": "distribution", "version": "1", "provides": [{"name": "artifact-store", "scope": "mesh"}], "capabilities": ["container-runtime"], @@ -137,8 +137,8 @@ func TestARegistryThatAnswersIsAccepted(t *testing.T) { } // Registered, assigned and pushed, through the same three commands a person types. for _, wanted := range []string{ - "module add /registry-module.json", - "assign anchor registry", + "module add /distribution-module.json", + "assign anchor distribution", "push anchor", } { if !runtime.ran(wanted) { From 21474b0144948b1a8c6afae1318863ab2b3641ae Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 15 Sep 2026 21:56:23 +0200 Subject: [PATCH 05/12] The installer goes as far as it can, and asks where a human must choose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/mesh-bootstrap/main.go | 95 ++++++++++++- cmd/mesh-bootstrap/main_test.go | 2 + internal/bootstrap/bootstrap.go | 71 +++++++++- internal/bootstrap/choices.go | 79 +++++++++++ internal/bootstrap/choices_test.go | 64 +++++++++ internal/bootstrap/phase2.go | 218 +++++++++++++++++++++++++++++ internal/bootstrap/phase2_test.go | 25 ++++ 7 files changed, 550 insertions(+), 4 deletions(-) create mode 100644 internal/bootstrap/choices.go create mode 100644 internal/bootstrap/choices_test.go create mode 100644 internal/bootstrap/phase2.go create mode 100644 internal/bootstrap/phase2_test.go diff --git a/cmd/mesh-bootstrap/main.go b/cmd/mesh-bootstrap/main.go index fc305fb..57deff0 100644 --- a/cmd/mesh-bootstrap/main.go +++ b/cmd/mesh-bootstrap/main.go @@ -28,9 +28,11 @@ import ( "syscall" "time" + "bufio" "github.com/novox/mesh-host/internal/apply" "github.com/novox/mesh-host/internal/bootstrap" "github.com/novox/mesh-host/internal/store" + "strings" ) // version is stamped at build time. Unset in a development build, and said so rather than @@ -50,7 +52,7 @@ const ( const usage = `mesh-bootstrap — make a bare machine into a mesh - bootstrap the twelve steps below (the default) + bootstrap the eighteen steps below (the default) version 1 preflight what has to be true before anything is changed @@ -67,6 +69,20 @@ const usage = `mesh-bootstrap — make a bare machine into a mesh 12 builder publish the carried builder and install it, so this mesh can make the rest of the catalogue rather than be handed it + Twelve make a mesh that RUNS. The rest make one that WORKS, asking where a + human must choose — a run without a terminal answers with the flags below: + + 13 base build the shared toolchain and runtime everything with code + stands on + 14 store build and install postgres — a database provider, which the + substrate's own store is not + 15 catalogue build and install the module graph + 16 network choose the private network (--private-network), place this + machine as its hub (--endpoint, --site) + 17 filter choose the packet filter (--packet-filter) — required, so the + question is which, not whether + 18 extras anything beyond the floor (--extras) + --bundle the substrate template to build this machine's bundle from (default ` + defaultTemplate + `) --out where the produced bundle is written, for a person to read @@ -96,6 +112,18 @@ const usage = `mesh-bootstrap — make a bare machine into a mesh --dry-run everything that does not change the machine --json machine-readable output + --tools-source the repository the shared base is built from + --tools-ref what of it to build (default main) + --catalog-source the catalogue REPOSITORY, for building its modules; + --catalog is the checkout that says what they are + --catalog-ref what of it to build (default main) + --private-network which private network to run (wireguard) + --endpoint host:port other machines dial for it; derived from the + broker address when unsaid + --site where this machine sits (default main) + --packet-filter which packet filter to run (nftables) + --extras catalogue modules beyond the floor, comma-separated + The installer carries a builder, not a control plane. What raises a mesh is therefore the same thing that will maintain it, and the control plane a mesh ends up running is one it built itself, from a repository and a commit it can name and build again. @@ -209,9 +237,67 @@ func newFlagSet(opts *bootstrap.Options, jsonOut *bool) *flag.FlagSet { set.DurationVar(&opts.Wait, "wait", opts.Wait, "how long something merely starting is given") set.BoolVar(&opts.DryRun, "dry-run", false, "everything that does not change the machine") set.BoolVar(jsonOut, "json", false, "machine-readable output") + + // Phase two — the installer goes as far as it can, and asks where a human must choose. A run + // without a terminal answers with these; a required choice nothing answered is a refusal. + set.StringVar(&opts.ToolsSource.Repository, "tools-source", opts.ToolsSource.Repository, + "the repository the shared base is built from") + set.StringVar(&opts.ToolsSource.Ref, "tools-ref", opts.ToolsSource.Ref, + "what of it to build (default main)") + set.StringVar(&opts.CatalogSource.Repository, "catalog-source", opts.CatalogSource.Repository, + "the catalogue REPOSITORY, for building its modules — --catalog is the checkout that says what they are") + set.StringVar(&opts.CatalogSource.Ref, "catalog-ref", opts.CatalogSource.Ref, + "what of it to build (default main)") + set.StringVar(&opts.Site, "site", "main", "where this machine sits, for the private network") + if opts.Answers == nil { + opts.Answers = map[string]string{} + } + answers := opts.Answers + set.Func("private-network", "which private network to run (wireguard)", func(v string) error { + answers["private-network"] = v + return nil + }) + set.Func("packet-filter", "which packet filter to run (nftables)", func(v string) error { + answers["packet-filter"] = v + return nil + }) + set.Func("endpoint", "host:port other machines dial for the private network (derived from the broker address if unsaid)", func(v string) error { + answers["endpoint"] = v + return nil + }) + set.Func("extras", "catalogue modules beyond the floor, comma-separated", func(v string) error { + for _, e := range strings.Split(v, ",") { + if e = strings.TrimSpace(e); e != "" { + opts.Extras = append(opts.Extras, e) + } + } + return nil + }) return set } +// askOn is how a person is asked a choice, when there is a person: the question, the options, a +// read line. Wired only when stdin is a terminal, so the lab and unattended runs are never left +// waiting on a prompt nobody will answer. +func askOn(in *bufio.Reader, out io.Writer) func(bootstrap.Choice) (string, error) { + return func(c bootstrap.Choice) (string, error) { + fmt.Fprintf(out, "\n%s\n", c.Question) + if len(c.Options) > 0 { + fmt.Fprintf(out, " options: %s\n", strings.Join(c.Options, ", ")) + } + if c.Default != "" { + fmt.Fprintf(out, " [%s] ", c.Default) + } else { + fmt.Fprint(out, " > ") + } + line, err := in.ReadString('\n') + if err != nil { + return "", fmt.Errorf("the terminal went away mid-question: %w", err) + } + return strings.TrimSpace(line), nil + } +} + func run(ctx context.Context, command string, opts bootstrap.Options, jsonOut bool) error { switch command { case "bootstrap": @@ -220,6 +306,13 @@ func run(ctx context.Context, command string, opts bootstrap.Options, jsonOut bo fmt.Println(line) } } + // A person at a terminal is asked the choices; anything else answers with flags. `--json` + // counts as "anything else": a run whose output is being parsed has no one reading a + // question. + if info, err := os.Stdin.Stat(); err == nil && + info.Mode()&os.ModeCharDevice != 0 && !jsonOut { + opts.Prompt = askOn(bufio.NewReader(os.Stdin), os.Stdout) + } result, err := bootstrap.Run(ctx, opts, bootstrap.Deps{ Run: apply.ExecRunner, Dial: dial, diff --git a/cmd/mesh-bootstrap/main_test.go b/cmd/mesh-bootstrap/main_test.go index 95815d1..896522e 100644 --- a/cmd/mesh-bootstrap/main_test.go +++ b/cmd/mesh-bootstrap/main_test.go @@ -108,6 +108,8 @@ func TestTheUsageTextAndTheFlagsAgree(t *testing.T) { for _, promised := range []string{ "bundle", "out", "state", "system", "timeout", "wait", "dry-run", "json", "catalog", "node", "registry", "host", "host-service", "host-in-background", + "tools-source", "tools-ref", "catalog-source", "catalog-ref", + "private-network", "endpoint", "site", "packet-filter", "extras", } { if !declared[promised] { t.Errorf("the usage text promises --%s and no such flag exists", promised) diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index dc2d0d5..5ad8bb4 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -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 diff --git a/internal/bootstrap/choices.go b/internal/bootstrap/choices.go new file mode 100644 index 0000000..3c85bd5 --- /dev/null +++ b/internal/bootstrap/choices.go @@ -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, ", ") +} diff --git a/internal/bootstrap/choices_test.go b/internal/bootstrap/choices_test.go new file mode 100644 index 0000000..d5e6075 --- /dev/null +++ b/internal/bootstrap/choices_test.go @@ -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) + } +} diff --git a/internal/bootstrap/phase2.go b/internal/bootstrap/phase2.go new file mode 100644 index 0000000..4eb21a3 --- /dev/null +++ b/internal/bootstrap/phase2.go @@ -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 +} diff --git a/internal/bootstrap/phase2_test.go b/internal/bootstrap/phase2_test.go new file mode 100644 index 0000000..cd212fc --- /dev/null +++ b/internal/bootstrap/phase2_test.go @@ -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") + } +} From 79863068fc810d70704c3b3ef8d8167120f513fa Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 16 Sep 2026 10:27:11 +0200 Subject: [PATCH 06/12] Genesis raises the package registry before it builds the base The base (mesh-tools) resolves the SDK by version from the mesh's package registry rather than cloning it from a git URL (hq ADR 0076, issue 053), so the registry has to answer and the SDK has to be in it before the base build runs. New steps, before base: seed gitea's database in the substrate store, raise gitea's server on it, create the admin/org/team and the builder's account, seal the builder its registry credential, and publish the SDK on a public base. gitea is adopted as an ordinary module after the base, so its provisioner image can be built. A minimal Go gitea admin client stands in until that module exists. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- cmd/mesh-bootstrap/main.go | 6 + internal/bootstrap/bootstrap.go | 18 ++ internal/bootstrap/phase_packages.go | 249 +++++++++++++++++++++ internal/bootstrap/phase_packages_gitea.go | 171 ++++++++++++++ 4 files changed, 444 insertions(+) create mode 100644 internal/bootstrap/phase_packages.go create mode 100644 internal/bootstrap/phase_packages_gitea.go diff --git a/cmd/mesh-bootstrap/main.go b/cmd/mesh-bootstrap/main.go index 57deff0..1c19326 100644 --- a/cmd/mesh-bootstrap/main.go +++ b/cmd/mesh-bootstrap/main.go @@ -117,6 +117,8 @@ const usage = `mesh-bootstrap — make a bare machine into a mesh --catalog-source the catalogue REPOSITORY, for building its modules; --catalog is the checkout that says what they are --catalog-ref what of it to build (default main) + --sdk-source the repository the shared library is built from + --sdk-ref what of it to build (default main) --private-network which private network to run (wireguard) --endpoint host:port other machines dial for it; derived from the broker address when unsaid @@ -248,6 +250,10 @@ func newFlagSet(opts *bootstrap.Options, jsonOut *bool) *flag.FlagSet { "the catalogue REPOSITORY, for building its modules — --catalog is the checkout that says what they are") set.StringVar(&opts.CatalogSource.Ref, "catalog-ref", opts.CatalogSource.Ref, "what of it to build (default main)") + set.StringVar(&opts.SDKSource.Repository, "sdk-source", opts.SDKSource.Repository, + "the repository the shared library is built from, published before the base resolves it") + set.StringVar(&opts.SDKSource.Ref, "sdk-ref", opts.SDKSource.Ref, + "what of it to build (default main)") set.StringVar(&opts.Site, "site", "main", "where this machine sits, for the private network") if opts.Answers == nil { opts.Answers = map[string]string{} diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 5ad8bb4..b633144 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -53,6 +53,8 @@ const ( StepControlPlane Step = "control-plane" StepRetire Step = "retire" StepBuilder Step = "builder" + StepPackages Step = "packages" + StepSDK Step = "sdk" StepBase Step = "base" StepStore Step = "store" StepCatalogue Step = "catalogue" @@ -75,6 +77,7 @@ const ( var Steps = []Step{ StepPreflight, StepLoad, StepBuild, StepBundle, StepApply, StepVerify, StepEnrol, StepRegistry, StepPublish, StepControlPlane, StepRetire, StepBuilder, + StepPackages, StepSDK, // 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. @@ -164,6 +167,9 @@ type Options struct { // 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 + // SDKSource is where the mesh's shared library is built from. It is published to the package + // registry before the base is built, because the base resolves it by version (novox/hq ADR 0076). + SDKSource 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. @@ -563,6 +569,18 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro return result, failed(StepBuilder, err) } + // ---- packages — the registry, before the base that resolves the SDK from it ---------- + say("packages — a registry answers, and the SDK is in it, before the base is built") + if err := RaisePackageRegistry(ctx, o, d, permanentControl, say); err != nil { + return result, failed(StepPackages, err) + } + + // ---- sdk — published on a public base, so this needs no toolchain -------------------- + say("sdk — the shared library, published by version so the base can resolve it") + if err := PublishTheSDK(ctx, o, permanentControl, say); err != nil { + return result, failed(StepSDK, err) + } + // ---- 13. base ------------------------------------------------------------------------- say("base — the shared toolchain and runtime everything with code stands on") if err := BuildBase(ctx, o, permanentControl, say); err != nil { diff --git a/internal/bootstrap/phase_packages.go b/internal/bootstrap/phase_packages.go new file mode 100644 index 0000000..57664ef --- /dev/null +++ b/internal/bootstrap/phase_packages.go @@ -0,0 +1,249 @@ +package bootstrap + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" +) + +// Raising the package registry, before the base is built. +// +// The base (mesh-tools) resolves the SDK by version from the mesh's package registry rather than +// cloning it from a git URL (novox/hq ADR 0076, issue 053). So the registry has to answer, and the +// SDK has to be in it, before the base build runs. That is a pivot like the control plane's: gitea's +// SERVER is raised directly here, on the substrate's own postgres, and adopted as an ordinary module +// only after the base exists (which is what lets its provisioner image — built on the base — run). +// +// Nothing here is the steady state. It is the smallest set of acts that puts a working npm registry +// in front of the base build: a database, a server, an admin, one org, the builder's own account, +// and the SDK published under it. The gitea MODULE, installed after the base, takes all of this over. + +const ( + // substrateStore is the substrate's postgres container — the mesh's own memory, raised from the + // bundle. gitea's bootstrap database lives here too, so a mesh runs one postgres (issue 051). + substrateStore = "mesh-store" + // giteaBootstrap is the gitea server raised directly at genesis, before gitea is a module. + giteaBootstrap = "mesh-gitea-server" + // giteaImage is the same upstream image the gitea module runs, pinned identically so the module + // adopts the running server rather than replacing it. + giteaImage = "gitea/gitea@sha256:dfc61e347c8b582df918f4556401bf2cecdfbdb56c5282ae9488dd76fca3e41c" + // packagesOrg is the npm owner: every module consumes `@novox/*` from this gitea org. + packagesOrg = "novox" + // packagesTeam is the org team whose members may read and write the org's packages. + packagesTeam = "packages" + // giteaAdminUser is the admin the bootstrap creates and the provisioner later authenticates as. + giteaAdminUser = "mesh-admin" + // builderGiteaUser is the gitea account the builder publishes and pulls with at genesis. It is + // the `as` the builder's static package binding names. + builderGiteaUser = "mesh-builder" + // giteaDBRole/giteaDBName is gitea's own database in the substrate store. + giteaDBRole = "mesh_gitea" + giteaDBName = "mesh_gitea" + // giteaPort is where the raised server answers on the machine. + giteaPort = 3000 +) + +// RaisePackageRegistry puts a working npm registry in front of the base build. It is idempotent: +// every step tolerates having been done, because genesis is safe to run again. +func RaisePackageRegistry(ctx context.Context, o Options, d Deps, control controlPlane, + say func(string)) error { + run := d.Run + + // The passwords the mesh mints for this pivot. gitea's database password and its admin password + // are the mesh's, generated here; the builder's is generated and also becomes its own-secret. + dbPassword := newPassword() + adminPassword := newPassword() + builderPassword := newPassword() + + say(" seeding gitea's database in the substrate store") + if err := seedGiteaDatabase(ctx, run, o.Timeout, dbPassword, say); err != nil { + return err + } + + say(" raising the gitea server on that database") + if err := raiseGiteaServer(ctx, run, o.Timeout, dbPassword, say); err != nil { + return err + } + + say(" waiting for gitea to answer") + base := fmt.Sprintf("http://127.0.0.1:%d", giteaPort) + if err := waitForGitea(ctx, d, o, base, say); err != nil { + return err + } + + say(" creating the gitea admin") + if err := createGiteaAdmin(ctx, run, o.Timeout, adminPassword, say); err != nil { + return err + } + + admin := &giteaAdmin{base: base, user: giteaAdminUser, password: adminPassword, + client: &http.Client{Timeout: o.Timeout}} + + say(" ensuring the npm org, its package team, and the builder's account") + if err := admin.ensureOrg(ctx, packagesOrg); err != nil { + return err + } + teamID, err := admin.ensureTeam(ctx, packagesOrg, packagesTeam) + if err != nil { + return err + } + if err := admin.ensureUser(ctx, builderGiteaUser, builderPassword); err != nil { + return err + } + if err := admin.addToTeam(ctx, teamID, builderGiteaUser); err != nil { + return err + } + + say(" delivering the builder its registry credential") + if err := deliverBuilderNpm(ctx, o, control, builderPassword, say); err != nil { + return err + } + + return nil +} + +// seedGiteaDatabase creates gitea's role and database inside the substrate postgres, the same way +// the substrate creates its own — psql run through the store container (the map's Route B). The role +// is created before the database because the database is owned by it. Both are tolerant of already +// existing, so a re-run changes nothing. +func seedGiteaDatabase(ctx context.Context, run Runner, timeout time.Duration, password string, + say func(string)) error { + asking, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + // A single transaction-free script: CREATE ROLE/DATABASE cannot run inside one, and DO blocks + // let "already there" be silent rather than an error the caller must parse. + script := fmt.Sprintf(` +DO $$ BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '%[1]s') THEN + CREATE ROLE %[1]s LOGIN PASSWORD '%[2]s'; + ELSE + ALTER ROLE %[1]s LOGIN PASSWORD '%[2]s'; + END IF; +END $$; +SELECT 'CREATE DATABASE %[3]s OWNER %[1]s' + WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '%[3]s')\gexec +`, giteaDBRole, password, giteaDBName) + + if _, err := run(asking, "docker", "exec", "-i", substrateStore, + "psql", "-U", "postgres", "-v", "ON_ERROR_STOP=1", "-c", script); err != nil { + return fmt.Errorf("could not seed gitea's database in %s: %w", substrateStore, err) + } + return nil +} + +// raiseGiteaServer starts the gitea server container against the substrate store. It joins the +// store's network namespace so `127.0.0.1:5432` reaches postgres, and publishes its own port on the +// machine so the builder and this installer can reach it. Started if absent, left alone if present. +func raiseGiteaServer(ctx context.Context, run Runner, timeout time.Duration, dbPassword string, + say func(string)) error { + asking, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + // Already there: a re-run does not raise a second one. `docker start` is a no-op on a running + // container and revives a stopped one. + if out, _ := run(asking, "docker", "inspect", "--format", "{{.Id}}", giteaBootstrap); strings.TrimSpace(out) != "" { + _, _ = run(asking, "docker", "start", giteaBootstrap) + return nil + } + + env := []string{ + "-e", "GITEA__database__DB_TYPE=postgres", + // The store is reached on the shared network namespace's loopback. + "-e", "GITEA__database__HOST=127.0.0.1:5432", + "-e", "GITEA__database__NAME=" + giteaDBName, + "-e", "GITEA__database__USER=" + giteaDBRole, + "-e", "GITEA__database__PASSWD=" + dbPassword, + // Skip the install wizard: the mesh configures gitea, not a person at a browser. + "-e", "GITEA__security__INSTALL_LOCK=true", + "-e", "USER_UID=1000", "-e", "USER_GID=1000", + } + args := append([]string{ + "run", "-d", "--name", giteaBootstrap, + "--network", "container:" + substrateStore, + "--restart", "unless-stopped", + }, env...) + args = append(args, giteaImage) + + if _, err := run(asking, "docker", args...); err != nil { + return fmt.Errorf("could not raise the gitea server: %w", err) + } + return nil +} + +// waitForGitea polls gitea's version endpoint until it answers or the wait runs out. A container +// that is up is not a forge that serves; `/api/v1/version` is the question whose answer means it is. +func waitForGitea(ctx context.Context, d Deps, o Options, base string, say func(string)) error { + deadline := time.Now().Add(o.Wait) + url := base + "/api/v1/version" + for { + status, _, err := d.Fetch(ctx, url) + if err == nil && status == http.StatusOK { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("gitea did not answer at %s within %s", url, o.Wait) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } + } +} + +// createGiteaAdmin creates the mesh's gitea admin through the server's own CLI. Tolerant of the +// admin already existing, because a re-run must not fail on it — and it resets the password every +// run, so a rotated admin secret takes. +func createGiteaAdmin(ctx context.Context, run Runner, timeout time.Duration, password string, + say func(string)) error { + asking, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + // Create, tolerating "already exists"; then set the password unconditionally so a re-run + // converges. Run as the gitea user, which owns the data the CLI reads. + create := fmt.Sprintf( + "gitea admin user create --admin --username %s --email %s@localhost --password %q --must-change-password=false || true; "+ + "gitea admin user change-password --username %s --password %q || true", + giteaAdminUser, giteaAdminUser, password, giteaAdminUser, password) + if _, err := run(asking, "docker", "exec", "-u", "git", giteaBootstrap, + "sh", "-c", create); err != nil { + return fmt.Errorf("could not create the gitea admin: %w", err) + } + return nil +} + +// deliverBuilderNpm seals the builder's registry password to this node as its `npm-password` +// own-secret, the same way the control plane's store connections are delivered — carry the value in, +// `secret accept`, and the next push writes it sealed where the builder reads it. +func deliverBuilderNpm(ctx context.Context, o Options, control controlPlane, password string, + say func(string)) error { + at := "/accepting-npm-password" + if err := control.carrying(ctx, "mesh-accepting-npm-password", []byte(password), at); err != nil { + return err + } + if _, err := control.tell(ctx, "secret", "accept", o.Node, BuilderModule, "npm-password", "--from", at); err != nil { + return err + } + // Push so the sealed secret reaches the builder, which restarts on it and comes back credentialed. + if _, err := control.tell(ctx, "push", o.Node); err != nil { + return err + } + return nil +} + +// PublishTheSDK dispatches a build of the SDK to the builder. The builder has its registry +// credential by now, so the build's `npm publish` authenticates; the artifact is a `package`, built +// on a public base, so this needs no toolchain — which is the whole point of doing it before the base. +func PublishTheSDK(ctx context.Context, o Options, control controlPlane, say func(string)) error { + if o.SDKSource.Repository == "" { + return fmt.Errorf("raising the registry needs --sdk-source: the SDK is built from its own " + + "repository, and an installer told nothing cannot know where that is") + } + say(" publishing " + o.SDKSource.Repository + " at " + refOr(o.SDKSource.Ref)) + _, err := control.within(buildWait).tell(ctx, + "build", o.SDKSource.Repository, "--ref", refOr(o.SDKSource.Ref), "--wait", "1200s") + return err +} diff --git a/internal/bootstrap/phase_packages_gitea.go b/internal/bootstrap/phase_packages_gitea.go new file mode 100644 index 0000000..304bd15 --- /dev/null +++ b/internal/bootstrap/phase_packages_gitea.go @@ -0,0 +1,171 @@ +package bootstrap + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// A minimal gitea admin client, for the genesis pivot only. The gitea MODULE carries the real one +// (its TS provisioner); this exists because at genesis that module cannot be built yet — its image +// stands on the base, which is what this is helping to build. It does the few acts the pivot needs +// and nothing more: an org, a team, a user, a membership. Everything is idempotent, because genesis +// is safe to run again. +type giteaAdmin struct { + base string + user string + password string + client *http.Client +} + +func (g *giteaAdmin) do(ctx context.Context, method, path string, body any) (int, []byte, error) { + var payload io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return 0, nil, err + } + payload = bytes.NewReader(raw) + } + req, err := http.NewRequestWithContext(ctx, method, g.base+"/api/v1"+path, payload) + if err != nil { + return 0, nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(g.user+":"+g.password))) + res, err := g.client.Do(req) + if err != nil { + return 0, nil, err + } + defer res.Body.Close() + out, _ := io.ReadAll(res.Body) + return res.StatusCode, out, nil +} + +// ok reports whether a status is one this pivot treats as success — the create succeeded, or the +// thing already exists (422/409), which for an idempotent step is the same outcome. +func ensured(status int) bool { + return status/100 == 2 || status == http.StatusUnprocessableEntity || status == http.StatusConflict +} + +func (g *giteaAdmin) ensureOrg(ctx context.Context, name string) error { + status, body, err := g.do(ctx, http.MethodPost, "/orgs", + map[string]any{"username": name, "visibility": "private"}) + if err != nil { + return err + } + if !ensured(status) { + return fmt.Errorf("could not create the gitea org %q: %d %s", name, status, body) + } + return nil +} + +// ensureTeam creates the org's package team with write on packages and returns its id, finding the +// existing one when a create loses to a concurrent one. +func (g *giteaAdmin) ensureTeam(ctx context.Context, org, team string) (int, error) { + if id, err := g.findTeam(ctx, org, team); err != nil { + return 0, err + } else if id != 0 { + return id, nil + } + status, body, err := g.do(ctx, http.MethodPost, "/orgs/"+org+"/teams", map[string]any{ + "name": team, + "permission": "read", + "units_map": map[string]string{"repo.packages": "write"}, + "includes_all_repositories": true, + "can_create_org_repo": false, + }) + if err != nil { + return 0, err + } + if status/100 == 2 { + var made struct { + ID int `json:"id"` + } + if err := json.Unmarshal(body, &made); err == nil && made.ID != 0 { + return made.ID, nil + } + } + // A lost race, or a body without an id: re-find. + if id, err := g.findTeam(ctx, org, team); err == nil && id != 0 { + return id, nil + } + return 0, fmt.Errorf("could not create the gitea team %q in %q: %d %s", team, org, status, body) +} + +func (g *giteaAdmin) findTeam(ctx context.Context, org, team string) (int, error) { + status, body, err := g.do(ctx, http.MethodGet, "/orgs/"+org+"/teams?limit=50", nil) + if err != nil { + return 0, err + } + if status != http.StatusOK { + return 0, nil + } + var teams []struct { + ID int `json:"id"` + Name string `json:"name"` + } + if err := json.Unmarshal(body, &teams); err != nil { + return 0, err + } + for _, t := range teams { + if t.Name == team { + return t.ID, nil + } + } + return 0, nil +} + +// ensureUser creates a gitea user with the mesh's minted password, or resets that user's password +// when it already exists, so a rotation takes. +func (g *giteaAdmin) ensureUser(ctx context.Context, name, password string) error { + status, body, err := g.do(ctx, http.MethodPost, "/admin/users", map[string]any{ + "username": name, + "email": name + "@localhost", + "password": password, + "must_change_password": false, + }) + if err != nil { + return err + } + if status/100 == 2 { + return nil + } + if status == http.StatusUnprocessableEntity || status == http.StatusConflict { + // Already there: reset the password so this run's credential is the one that works. + reset, rbody, err := g.do(ctx, http.MethodPatch, "/admin/users/"+name, + map[string]any{"login_name": name, "password": password, "must_change_password": false}) + if err != nil { + return err + } + if reset/100 == 2 { + return nil + } + return fmt.Errorf("could not reset the gitea user %q: %d %s", name, reset, rbody) + } + return fmt.Errorf("could not create the gitea user %q: %d %s", name, status, body) +} + +func (g *giteaAdmin) addToTeam(ctx context.Context, teamID int, user string) error { + status, body, err := g.do(ctx, http.MethodPut, fmt.Sprintf("/teams/%d/members/%s", teamID, user), nil) + if err != nil { + return err + } + if !ensured(status) { + return fmt.Errorf("could not add %q to team %d: %d %s", user, teamID, status, body) + } + return nil +} + +// newPassword is a mesh-minted secret: 32 bytes of randomness, URL-safe so it survives a connection +// string and an .npmrc without escaping. +func newPassword() string { + b := make([]byte, 32) + _, _ = rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} From 1a7c9fdac32c6d38e5a8d1be05010baacfee8440 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 16 Sep 2026 10:33:07 +0200 Subject: [PATCH 07/12] gitea runs on the host network at genesis So it reaches the substrate store's loopback-published postgres and answers where mesh-bootstrap and the builder look for it. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- internal/bootstrap/phase_packages.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/bootstrap/phase_packages.go b/internal/bootstrap/phase_packages.go index 57664ef..80d5292 100644 --- a/internal/bootstrap/phase_packages.go +++ b/internal/bootstrap/phase_packages.go @@ -162,7 +162,10 @@ func raiseGiteaServer(ctx context.Context, run Runner, timeout time.Duration, db } args := append([]string{ "run", "-d", "--name", giteaBootstrap, - "--network", "container:" + substrateStore, + // Host network, like the control plane: it reaches the substrate store on the machine's + // loopback (where the store publishes 5432) and answers on the machine's own 3000, which is + // where mesh-bootstrap and the builder's build containers look for it. + "--network", "host", "--restart", "unless-stopped", }, env...) args = append(args, giteaImage) From 01c7730fb3a19ca22fcd03e54d3651e905fe8f85 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 16 Sep 2026 14:11:36 +0200 Subject: [PATCH 08/12] Genesis raises gitea correctly: host network, honest SQL, matched ROOT_URL Fixes found raising the package registry end-to-end in the lab: seed gitea's DB with plain psql statements (no \gexec, no $$ DO-blocks that clash with the shell); run gitea on the host network so it reaches the substrate store and answers where the builder looks; set gitea ROOT_URL to the machine's loopback so npm's stored credential matches the tarball host; keep the pivot's passwords so a re-run is the same run; create the admin without re-enabling must-change-password. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- internal/bootstrap/phase_packages.go | 100 +++++++++++++++------ internal/bootstrap/phase_packages_gitea.go | 19 +++- 2 files changed, 90 insertions(+), 29 deletions(-) diff --git a/internal/bootstrap/phase_packages.go b/internal/bootstrap/phase_packages.go index 80d5292..10be2b7 100644 --- a/internal/bootstrap/phase_packages.go +++ b/internal/bootstrap/phase_packages.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "os" "strings" "time" ) @@ -51,11 +52,12 @@ func RaisePackageRegistry(ctx context.Context, o Options, d Deps, control contro say func(string)) error { run := d.Run - // The passwords the mesh mints for this pivot. gitea's database password and its admin password - // are the mesh's, generated here; the builder's is generated and also becomes its own-secret. - dbPassword := newPassword() - adminPassword := newPassword() - builderPassword := newPassword() + // The passwords this pivot mints, kept once so a re-run is the same run: gitea already holds + // them, so regenerating would lock the mesh out of the forge it just raised. + dbPassword, adminPassword, builderPassword, err := packagePasswords() + if err != nil { + return err + } say(" seeding gitea's database in the substrate store") if err := seedGiteaDatabase(ctx, run, o.Timeout, dbPassword, say); err != nil { @@ -113,23 +115,33 @@ func seedGiteaDatabase(ctx context.Context, run Runner, timeout time.Duration, p asking, cancel := context.WithTimeout(ctx, timeout) defer cancel() - // A single transaction-free script: CREATE ROLE/DATABASE cannot run inside one, and DO blocks - // let "already there" be silent rather than an error the caller must parse. - script := fmt.Sprintf(` -DO $$ BEGIN - IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '%[1]s') THEN - CREATE ROLE %[1]s LOGIN PASSWORD '%[2]s'; - ELSE - ALTER ROLE %[1]s LOGIN PASSWORD '%[2]s'; - END IF; -END $$; -SELECT 'CREATE DATABASE %[3]s OWNER %[1]s' - WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '%[3]s')\gexec -`, giteaDBRole, password, giteaDBName) + // Single statements through psql -c, not one script: CREATE DATABASE cannot run in a + // transaction and \gexec does not parse through -c. The password is base64url, so it carries no + // quote or backslash to escape inside a SQL literal. + psql := func(sql string) (string, error) { + return run(asking, "docker", "exec", substrateStore, "psql", "-U", "postgres", "-tAc", sql) + } - if _, err := run(asking, "docker", "exec", "-i", substrateStore, - "psql", "-U", "postgres", "-v", "ON_ERROR_STOP=1", "-c", script); err != nil { - return fmt.Errorf("could not seed gitea's database in %s: %w", substrateStore, err) + // The role: create it, and if it is already there (create fails) reset its password so a re-run + // converges on this run's credential. + create := fmt.Sprintf("CREATE ROLE %s LOGIN PASSWORD '%s'", giteaDBRole, password) + if _, err := psql(create); err != nil { + alter := fmt.Sprintf("ALTER ROLE %s LOGIN PASSWORD '%s'", giteaDBRole, password) + if _, err := psql(alter); err != nil { + return fmt.Errorf("could not create gitea's role in %s: %w", substrateStore, err) + } + } + + // The database: created only if absent, because CREATE DATABASE has no IF NOT EXISTS and a + // second create is an error rather than a no-op. + present, err := psql(fmt.Sprintf("SELECT 1 FROM pg_database WHERE datname='%s'", giteaDBName)) + if err != nil { + return fmt.Errorf("could not check for gitea's database in %s: %w", substrateStore, err) + } + if strings.TrimSpace(present) != "1" { + if _, err := psql(fmt.Sprintf("CREATE DATABASE %s OWNER %s", giteaDBName, giteaDBRole)); err != nil { + return fmt.Errorf("could not create gitea's database in %s: %w", substrateStore, err) + } } return nil } @@ -158,6 +170,11 @@ func raiseGiteaServer(ctx context.Context, run Runner, timeout time.Duration, db "-e", "GITEA__database__PASSWD=" + dbPassword, // Skip the install wizard: the mesh configures gitea, not a person at a browser. "-e", "GITEA__security__INSTALL_LOCK=true", + // The forge answers on the machine's loopback, and its own links must say so: gitea's + // package metadata hands npm a tarball URL built from ROOT_URL, and a client only sends its + // stored credential to the host it was stored for. A default ROOT_URL of localhost is a + // different host than the binding's 127.0.0.1, so the credential would not be sent. + "-e", fmt.Sprintf("GITEA__server__ROOT_URL=http://127.0.0.1:%d/", giteaPort), "-e", "USER_UID=1000", "-e", "USER_GID=1000", } args := append([]string{ @@ -205,19 +222,48 @@ func createGiteaAdmin(ctx context.Context, run Runner, timeout time.Duration, pa asking, cancel := context.WithTimeout(ctx, timeout) defer cancel() - // Create, tolerating "already exists"; then set the password unconditionally so a re-run - // converges. Run as the gitea user, which owns the data the CLI reads. + // Create once, with the password kept across re-runs, and do not follow with change-password: + // change-password re-enables must-change-password, which then refuses every API call as + // "you must change your password". Tolerant of "already exists", because the kept password + // means the existing admin is already the one this run authenticates as. create := fmt.Sprintf( - "gitea admin user create --admin --username %s --email %s@localhost --password %q --must-change-password=false || true; "+ - "gitea admin user change-password --username %s --password %q || true", - giteaAdminUser, giteaAdminUser, password, giteaAdminUser, password) + "gitea admin user create --admin --username %s --email %s@localhost --password %q --must-change-password=false", + giteaAdminUser, giteaAdminUser, password) if _, err := run(asking, "docker", "exec", "-u", "git", giteaBootstrap, - "sh", "-c", create); err != nil { + "sh", "-c", create+" || true"); err != nil { return fmt.Errorf("could not create the gitea admin: %w", err) } return nil } +// packagePasswords loads the pivot's three passwords, minting and keeping them the first time. Kept +// on the machine because gitea, once raised, holds them: a second genesis that minted fresh ones +// would raise a forge it cannot then log into. +func packagePasswords() (db, admin, builder string, err error) { + const dir = "/var/lib/mesh/packages" + const file = dir + "/bootstrap.env" + if raw, e := os.ReadFile(file); e == nil { + vals := map[string]string{} + for _, line := range strings.Split(string(raw), "\n") { + if k, v, ok := strings.Cut(strings.TrimSpace(line), "="); ok { + vals[k] = v + } + } + if vals["DB"] != "" && vals["ADMIN"] != "" && vals["BUILDER"] != "" { + return vals["DB"], vals["ADMIN"], vals["BUILDER"], nil + } + } + db, admin, builder = newPassword(), newPassword(), newPassword() + if err = os.MkdirAll(dir, 0o700); err != nil { + return "", "", "", err + } + content := fmt.Sprintf("DB=%s\nADMIN=%s\nBUILDER=%s\n", db, admin, builder) + if err = os.WriteFile(file, []byte(content), 0o600); err != nil { + return "", "", "", err + } + return db, admin, builder, nil +} + // deliverBuilderNpm seals the builder's registry password to this node as its `npm-password` // own-secret, the same way the control plane's store connections are delivered — carry the value in, // `secret accept`, and the next push writes it sealed where the builder reads it. diff --git a/internal/bootstrap/phase_packages_gitea.go b/internal/bootstrap/phase_packages_gitea.go index 304bd15..f44a618 100644 --- a/internal/bootstrap/phase_packages_gitea.go +++ b/internal/bootstrap/phase_packages_gitea.go @@ -124,9 +124,12 @@ func (g *giteaAdmin) findTeam(ctx context.Context, org, team string) (int, error // ensureUser creates a gitea user with the mesh's minted password, or resets that user's password // when it already exists, so a rotation takes. func (g *giteaAdmin) ensureUser(ctx context.Context, name, password string) error { + // A dotted domain: gitea's API validates the address, and an @localhost with no dot is refused + // as malformed — which comes back as the same 422 an "already exists" does, so the email is + // chosen to not provoke it and existence is checked directly rather than inferred from a status. status, body, err := g.do(ctx, http.MethodPost, "/admin/users", map[string]any{ "username": name, - "email": name + "@localhost", + "email": name + "@packages.mesh.local", "password": password, "must_change_password": false, }) @@ -136,7 +139,11 @@ func (g *giteaAdmin) ensureUser(ctx context.Context, name, password string) erro if status/100 == 2 { return nil } - if status == http.StatusUnprocessableEntity || status == http.StatusConflict { + exists, err := g.userExists(ctx, name) + if err != nil { + return err + } + if exists { // Already there: reset the password so this run's credential is the one that works. reset, rbody, err := g.do(ctx, http.MethodPatch, "/admin/users/"+name, map[string]any{"login_name": name, "password": password, "must_change_password": false}) @@ -151,6 +158,14 @@ func (g *giteaAdmin) ensureUser(ctx context.Context, name, password string) erro return fmt.Errorf("could not create the gitea user %q: %d %s", name, status, body) } +func (g *giteaAdmin) userExists(ctx context.Context, name string) (bool, error) { + status, _, err := g.do(ctx, http.MethodGet, "/users/"+name, nil) + if err != nil { + return false, err + } + return status == http.StatusOK, nil +} + func (g *giteaAdmin) addToTeam(ctx context.Context, teamID int, user string) error { status, body, err := g.do(ctx, http.MethodPut, fmt.Sprintf("/teams/%d/members/%s", teamID, user), nil) if err != nil { From 121367319d2557199865a7489b4583354200e3f6 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 16 Sep 2026 18:40:40 +0200 Subject: [PATCH 09/12] Rename mesh-control -> mesh-controller, substrate -> foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One name per thing, per the HQ glossary: the module/container/image/binary/repo becomes mesh-controller, the seat the-controller, and the store+broker pair the foundation (embedded base bundles, default template and example lock renamed with their go:embed directives). No behaviour change — a pure vocabulary rename. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- Makefile | 16 +-- README.md | 20 ++-- cmd/mesh-bootstrap/main.go | 24 ++--- cmd/mesh-bootstrap/main_test.go | 4 +- cmd/mesh-host/main.go | 2 +- examples/README.md | 10 +- examples/bundle_test.go | 18 ++-- ...t-node.lock => foundation-first-node.lock} | 12 +-- internal/apply/apply_test.go | 2 +- internal/bootstrap/apply.go | 8 +- internal/bootstrap/apply_test.go | 6 +- internal/bootstrap/bootstrap.go | 32 +++--- internal/bootstrap/build_test.go | 4 +- internal/bootstrap/control.go | 40 ++++---- internal/bootstrap/control_test.go | 98 +++++++++---------- internal/bootstrap/enrol.go | 2 +- internal/bootstrap/enrol_test.go | 12 +-- internal/bootstrap/load.go | 2 +- internal/bootstrap/load_test.go | 16 +-- internal/bootstrap/module.go | 2 +- internal/bootstrap/phase_packages.go | 26 ++--- internal/bootstrap/preflight.go | 8 +- internal/bootstrap/preflight_test.go | 6 +- internal/bootstrap/publish.go | 10 +- internal/bootstrap/publish_test.go | 18 ++-- internal/bootstrap/registry.go | 4 +- internal/bootstrap/registry_test.go | 14 +-- internal/bootstrap/retire.go | 8 +- internal/bootstrap/retire_test.go | 10 +- internal/bootstrap/rewrite.go | 38 +++---- internal/bootstrap/rewrite_test.go | 54 +++++----- internal/bootstrap/talk.go | 12 +-- internal/bootstrap/verify.go | 10 +- internal/bootstrap/verify_test.go | 30 +++--- internal/bundle/bundle.go | 8 +- internal/bundle/bundle_test.go | 2 +- ...ate-alpine.lock => foundation-alpine.lock} | 2 +- ...e-android.lock => foundation-android.lock} | 6 +- ...bstrate-arch.lock => foundation-arch.lock} | 2 +- internal/declaration/declaration.go | 2 +- internal/declaration/declaration_test.go | 10 +- internal/image/builder.tar | 2 +- internal/image/image.go | 2 +- internal/image/image_test.go | 6 +- internal/link/messages.go | 2 +- internal/store/store.go | 6 +- internal/store/store_test.go | 4 +- internal/system/android.go | 2 +- 48 files changed, 317 insertions(+), 317 deletions(-) rename examples/{substrate-first-node.lock => foundation-first-node.lock} (94%) rename internal/bundle/{substrate-alpine.lock => foundation-alpine.lock} (87%) rename internal/bundle/{substrate-android.lock => foundation-android.lock} (66%) rename internal/bundle/{substrate-arch.lock => foundation-arch.lock} (88%) diff --git a/Makefile b/Makefile index 4b92c97..c295f39 100644 --- a/Makefile +++ b/Makefile @@ -36,24 +36,24 @@ test: go test ./... -count=1 # A default build carries no bundle and refuses to reconcile, which is the honest state for a -# host nobody has told what a substrate is. +# host nobody has told what a foundation is. 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 SYSTEM=arch BUNDLE=path/to/substrate.lock +# make host SYSTEM=arch BUNDLE=path/to/foundation.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; } - @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 + @test -f internal/bundle/foundation-$(SYSTEM).lock || { echo "no bundle slot for SYSTEM=$(SYSTEM)"; exit 1; } + @cp internal/bundle/foundation-$(SYSTEM).lock internal/bundle/foundation-$(SYSTEM).lock.default + @cp "$(BUNDLE)" internal/bundle/foundation-$(SYSTEM).lock @CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o mesh-host ./cmd/mesh-host; \ status=$$?; \ - mv internal/bundle/substrate-$(SYSTEM).lock.default internal/bundle/substrate-$(SYSTEM).lock; \ + mv internal/bundle/foundation-$(SYSTEM).lock.default internal/bundle/foundation-$(SYSTEM).lock; \ exit $$status @echo "built for $(SYSTEM) carrying $(BUNDLE)" @@ -66,7 +66,7 @@ host: # answered by ADR 0071: the source comes from a mesh that already exists, which is not the one being # raised. What cannot be fetched is the thing that does the fetching, and that is what is carried. # -# The image is BUILT ELSEWHERE and handed over — mesh-control's own `make builder-image` — and +# The image is BUILT ELSEWHERE and handed over — mesh-controller's own `make builder-image` — and # embedded here at release time, the same way carrying the bundle breaks the "copy it onto a machine # and run it" cycle (novox/hq ADR 0005). # @@ -90,7 +90,7 @@ BOOTSTRAP_OUT ?= mesh-bootstrap bootstrap: @test -n "$(IMAGE)" || { echo "IMAGE= is required; an installer carrying no builder image cannot raise a mesh"; exit 1; } @case "$(IMAGE)" in sha256:*) echo "IMAGE=$(IMAGE) is an image id. The installer identifies the carried image by its tag, because an id is the digest of a configuration that a runtime rewrites as it loads. Pass a name:tag"; exit 1;; esac - @docker image inspect "$(IMAGE)" >/dev/null 2>&1 || { echo "this machine does not hold $(IMAGE) — build it in mesh-control with 'make image'"; exit 1; } + @docker image inspect "$(IMAGE)" >/dev/null 2>&1 || { echo "this machine does not hold $(IMAGE) — build it in mesh-controller with 'make image'"; exit 1; } @test -n "$$(docker image inspect --format '{{len .RepoTags}}' "$(IMAGE)" | grep -v '^0$$')" || { echo "$(IMAGE) has no repository tag, so the saved archive would carry no name the installer can ask a runtime about. Tag it first: docker tag $(IMAGE) mesh-builder:"; exit 1; } @cp internal/image/builder.tar internal/image/builder.tar.placeholder @docker save --output internal/image/builder.tar "$(IMAGE)" diff --git a/README.md b/README.md index d440a3b..0b051a4 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ the fault this exists to prevent. A host built for a machine carries its declaration **inside the binary**: ``` -make host BUNDLE=path/to/substrate.lock +make host BUNDLE=path/to/foundation.lock ``` `mesh-host reconcile` then applies it. That is the first node's path — no mesh present, nothing @@ -126,21 +126,21 @@ Stages 3 and 4 — the link, and enrolment — are designed and not built. ## What stage 2 does not yet prove -The design defines stage 2 as *the host applies `substrate.lock` with no mesh present*, and -calls out the claim underneath it: **that one host can raise the substrate alone**. +The design defines stage 2 as *the host applies `foundation.lock` with no mesh present*, and +calls out the claim underneath it: **that one host can raise the foundation alone**. The mechanism is proved — a sealed machine, one binary, and it configures itself from what it -carries. The claim is not. The substrate is four container services, and: +carries. The claim is not. The foundation is four container services, and: - the vocabulary has no container type, because a container needs an image and where images come from is open ([`novox/hq` research 012](https://git.novox.be/novox/hq)); -- what belongs in a substrate is not known — the closure for a one-node mesh is what +- what belongs in a foundation is not known — the closure for a one-node mesh is what research 011 and 012 exist to answer; - and the machine used to test this has no container runtime, because a sealed network cannot install one. -So `substrate.lock` here is a real bundle with a placeholder's content. Saying that plainly -beats shipping a host that claims a substrate it has never raised. +So `foundation.lock` here is a real bundle with a placeholder's content. Saying that plainly +beats shipping a host that claims a foundation it has never raised. ## A capability is detected, never assumed @@ -198,7 +198,7 @@ repository carries implementation and does not carry decisions. ## Checks that cross into the control plane's repository -Two things are agreed between this repository and `novox/mesh-control`, and each is a separate +Two things are agreed between this repository and `novox/mesh-controller`, and each is a separate struct on each side. A field renamed on one of them fails **silently** — the crossing succeeds and something is simply absent — so both are checked by handing one side's real output to the other's real parser. Neither runs by default; each skips with a reason, because a repository that fails @@ -207,7 +207,7 @@ without its neighbour checked out is a repository nobody can build. **What the mesh sends, read by this host:** ``` -mesh-control: ./build/mesh-control plan --json > /tmp/d.json +mesh-controller: ./build/mesh-controller plan --json > /tmp/d.json mesh-host: MESH_EMITTED=/tmp/d.json go test ./internal/declaration/ -v ``` @@ -215,7 +215,7 @@ mesh-host: MESH_EMITTED=/tmp/d.json go test ./internal/declaration/ -v ``` mesh-host: MESH_ENROL_OUT=/tmp/enrol.json go test ./internal/link/ -mesh-control: MESH_ENROL=/tmp/enrol.json make check +mesh-controller: MESH_ENROL=/tmp/enrol.json make check ``` The second writes the private half of the sealing key beside the request, so the mesh's suite can diff --git a/cmd/mesh-bootstrap/main.go b/cmd/mesh-bootstrap/main.go index 1c19326..5f28e15 100644 --- a/cmd/mesh-bootstrap/main.go +++ b/cmd/mesh-bootstrap/main.go @@ -8,11 +8,11 @@ // cannot be folded into it without making that sentence false. Same tier, same repository, // different program. // -// Genesis is a pivot (novox/hq ADR 0067). It raises a substrate whose control plane is named by the +// Genesis is a pivot (novox/hq ADR 0067). It raises a foundation whose control plane is named by the // digest of its own configuration — legal exactly where nothing could have served an image — then // enrols this machine, installs the registry module, pushes that image into it to get the manifest // digest it has never had, reinstalls the control plane as an ordinary module pinned to it, and -// drops the temporary one. Without --catalog it stops after the substrate and says why. +// drops the temporary one. Without --catalog it stops after the foundation and says why. package main import ( @@ -40,8 +40,8 @@ import ( var version = "development build" const ( - defaultTemplate = "substrate.lock" - defaultOut = "/var/lib/mesh-host/substrate.lock" + defaultTemplate = "foundation.lock" + defaultOut = "/var/lib/mesh-host/foundation.lock" defaultRegistry = "127.0.0.1:5000" defaultHost = "/usr/local/bin/mesh-host" // The unit this project actually packages, in `packaging/`. It said `mesh-host.service`, which @@ -58,7 +58,7 @@ const usage = `mesh-bootstrap — make a bare machine into a mesh 1 preflight what has to be true before anything is changed 2 load the builder's image, carried in this installer 3 build the control plane, from its own repository and a commit - 4 bundle the substrate, named for this machine + 4 bundle the foundation, named for this machine 5 apply raise it 6 verify it is up, and the control plane replies 7 enrol this machine becomes the mesh's first node @@ -75,7 +75,7 @@ const usage = `mesh-bootstrap — make a bare machine into a mesh 13 base build the shared toolchain and runtime everything with code stands on 14 store build and install postgres — a database provider, which the - substrate's own store is not + foundation's own store is not 15 catalogue build and install the module graph 16 network choose the private network (--private-network), place this machine as its hub (--endpoint, --site) @@ -83,7 +83,7 @@ const usage = `mesh-bootstrap — make a bare machine into a mesh question is which, not whether 18 extras anything beyond the floor (--extras) - --bundle the substrate template to build this machine's bundle from + --bundle the foundation template to build this machine's bundle from (default ` + defaultTemplate + `) --out where the produced bundle is written, for a person to read (default ` + defaultOut + `) @@ -131,8 +131,8 @@ the same thing that will maintain it, and the control plane a mesh ends up runni one it built itself, from a repository and a commit it can name and build again. Genesis is a pivot: a temporary control plane installs the registry that makes it -permanent. The temporary one is called temp-mesh-control and the permanent one is -called mesh-control, so they are two containers with two owners and there is nothing +permanent. The temporary one is called temp-mesh-controller and the permanent one is +called mesh-controller, so they are two containers with two owners and there is nothing to hand over. Every step is idempotent: run it again after fixing whatever it named, and the steps @@ -217,11 +217,11 @@ func newFlagSet(opts *bootstrap.Options, jsonOut *bool) *flag.FlagSet { set := flag.NewFlagSet("mesh-bootstrap", flag.ContinueOnError) set.SetOutput(os.Stderr) set.Usage = func() { fmt.Fprint(os.Stderr, usage) } - set.StringVar(&opts.Template, "bundle", opts.Template, "the substrate template to build from") + set.StringVar(&opts.Template, "bundle", opts.Template, "the foundation template to build from") set.StringVar(&opts.Out, "out", opts.Out, "where the produced bundle is written") set.StringVar(&opts.State, "state", opts.State, "where this node records what it has applied") set.StringVar(&opts.Catalogue, "catalog", opts.Catalogue, - "a checkout of the mesh's catalogue; without it this stops after the substrate") + "a checkout of the mesh's catalogue; without it this stops after the foundation") set.StringVar(&opts.Source.Repository, "source", opts.Source.Repository, "the repository the control plane is built from, on a mesh that already exists") set.StringVar(&opts.Source.Ref, "source-ref", opts.Source.Ref, @@ -367,7 +367,7 @@ func hostname() string { // // Plain HTTP, and only at the mesh's own registry: it is reached over the mesh's private network, // which is already the encrypted and authenticated thing, and a second layer inside it would be -// certificates to issue and rotate for no property the first does not have (mesh-control's +// certificates to issue and rotate for no property the first does not have (mesh-controller's // `internal/builder` pushes to it on the same reasoning). // // The body is read with a limit. What is asked for is a status and a short JSON answer, and a diff --git a/cmd/mesh-bootstrap/main_test.go b/cmd/mesh-bootstrap/main_test.go index 896522e..2fadbcb 100644 --- a/cmd/mesh-bootstrap/main_test.go +++ b/cmd/mesh-bootstrap/main_test.go @@ -57,7 +57,7 @@ func TestAMistypedFlagIsRefusedNotIgnored(t *testing.T) { } func TestAnUnexpectedArgumentIsRefused(t *testing.T) { - if _, _, _, err := parseArgs([]string{"bootstrap", "substrate.lock"}); err == nil { + if _, _, _, err := parseArgs([]string{"bootstrap", "foundation.lock"}); err == nil { t.Fatal("a stray argument was ignored rather than refused — the bundle is --bundle") } } @@ -141,7 +141,7 @@ func TestThePivotsDefaultsAreTheDocumentedOnes(t *testing.T) { // be a checkout somebody else made, at whatever commit they left it on — and it decides which // image the mesh's control plane is pinned to for ever after. if opts.Catalogue != "" { - t.Errorf("--catalog defaults to %q; without one the installer stops at the substrate", + t.Errorf("--catalog defaults to %q; without one the installer stops at the foundation", opts.Catalogue) } // The machine's own name, because that is what a person already calls it. diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 2095c26..73157a0 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -823,7 +823,7 @@ func sealOpener(statePath string) apply.Unseal { // carriedPorts is every machine port held by what this host raised from its own bundle. // -// **What the mesh must assign around** (novox/hq ADR 0038). The substrate is not a module: a node +// **What the mesh must assign around** (novox/hq ADR 0038). The foundation is not a module: a node // raises it before any mesh exists, so the control plane has never heard of the store or the // broker. Told this, it can put a module somewhere else; not told, it hands out a port one of them // holds and finds out from a container runtime. diff --git a/examples/README.md b/examples/README.md index 687430a..c3a0258 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,17 +1,17 @@ # Examples -## `substrate-first-node.lock` +## `foundation-first-node.lock` What a machine must be before a mesh exists — the bootstrap in -[novox/hq `07-the-substrate.md`](https://git.novox.be/novox/hq), whole: +[novox/hq `07-the-foundation.md`](https://git.novox.be/novox/hq), whole: ``` 0 a container runtime 1 the store runs 2 a database per context `inventory` and `identity` -3 those contexts' schemas mesh-control migrate +3 those contexts' schemas mesh-controller migrate 4 the broker runs with a certificate it generated itself -5 the control plane runs mesh-control serve +5 the control plane runs mesh-controller serve ``` **A machine that applies this is a mesh** — one node, with nothing joined to it yet, which is @@ -24,7 +24,7 @@ a comment about what something does not do is a comment nobody updates. Build a host carrying it: ``` -make host SYSTEM=arch BUNDLE=examples/substrate-first-node.lock +make host SYSTEM=arch BUNDLE=examples/foundation-first-node.lock ``` **The registry address and digests have to be replaced before this is useful.** They are written diff --git a/examples/bundle_test.go b/examples/bundle_test.go index 47ce39d..07e75da 100644 --- a/examples/bundle_test.go +++ b/examples/bundle_test.go @@ -1,6 +1,6 @@ // Package examples checks the bundles shipped in this directory. // -// **Nothing checked them before.** `substrate-first-node.lock` is what a machine becomes when +// **Nothing checked them before.** `foundation-first-node.lock` is what a machine becomes when // there is no mesh to ask — the one declaration applied with nothing to verify it against — and // it was edited by hand and read by nobody but a running host. package examples @@ -16,7 +16,7 @@ import ( func bundle(t *testing.T) *declaration.Declaration { t.Helper() - raw, err := os.ReadFile("substrate-first-node.lock") + raw, err := os.ReadFile("foundation-first-node.lock") if err != nil { t.Fatal(err) } @@ -27,12 +27,12 @@ func bundle(t *testing.T) *declaration.Declaration { return d } -// Defends novox/hq ADR 0028: the substrate supplies the control plane and nothing else. +// Defends novox/hq ADR 0028: the foundation supplies the control plane and nothing else. // -// The object store was substrate for months on the strength of "it cannot grant itself a bucket", +// The object store was foundation for months on the strength of "it cannot grant itself a bucket", // which answers half the test. The control plane never needed one, and nothing noticed because // nothing counted what the bundle holds. -func TestTheBundleCarriesTheSubstrateAndTheControlPlaneAndNothingElse(t *testing.T) { +func TestTheBundleCarriesTheFoundationAndTheControlPlaneAndNothingElse(t *testing.T) { var images []string for _, r := range bundle(t).Resources { c, ok := r.(*declaration.Container) @@ -48,7 +48,7 @@ func TestTheBundleCarriesTheSubstrateAndTheControlPlaneAndNothingElse(t *testing } sort.Strings(images) - want := []string{"lavinmq", "mesh-control", "postgres"} + want := []string{"lavinmq", "mesh-controller", "postgres"} if strings.Join(images, ",") != strings.Join(want, ",") { t.Fatalf("the bundle carries %v; expected exactly %v.\n\n"+ "Adding one is a change to what every first node becomes, and to ADR 0006's "+ @@ -57,13 +57,13 @@ func TestTheBundleCarriesTheSubstrateAndTheControlPlaneAndNothingElse(t *testing } // The control plane is in the bundle, and the design overlooked it once by reasoning about -// substrate services rather than counting containers (novox/hq 03-DESIGN/01-to-be/07). +// foundation services rather than counting containers (novox/hq 03-DESIGN/01-to-be/07). func TestTheControlPlaneIsCarriedToo(t *testing.T) { for _, r := range bundle(t).Resources { - if c, ok := r.(*declaration.Container); ok && strings.Contains(c.Image, "mesh-control") { + if c, ok := r.(*declaration.Container); ok && strings.Contains(c.Image, "mesh-controller") { return } } t.Fatal("nothing in the bundle starts the control plane, so the machine would raise a " + - "substrate and stop") + "foundation and stop") } diff --git a/examples/substrate-first-node.lock b/examples/foundation-first-node.lock similarity index 94% rename from examples/substrate-first-node.lock rename to examples/foundation-first-node.lock index 2fcefe6..3f516b1 100644 --- a/examples/substrate-first-node.lock +++ b/examples/foundation-first-node.lock @@ -1,6 +1,6 @@ -// substrate-first-node.lock — what a machine must be before a mesh exists. +// foundation-first-node.lock — what a machine must be before a mesh exists. // -// The whole bootstrap (novox/hq 03-DESIGN/01-to-be/07-the-substrate.md): a container runtime, a +// The whole bootstrap (novox/hq 03-DESIGN/01-to-be/07-the-foundation.md): a container runtime, a // store, a database per context, those contexts' schemas, the broker, and the control plane // running on top of them. // @@ -85,7 +85,7 @@ }, // Each context owns its own database (novox/hq ADR 0008). A third one is a third database, // created the same way and named the same way — which is the whole of adding a context to the - // bootstrap, and is why the count is not something the substrate has an opinion about. + // bootstrap, and is why the count is not something the foundation has an opinion about. { "id": "licences-database", "type": "action", @@ -100,7 +100,7 @@ "-e", "MESH_STORE_INVENTORY=postgres://postgres:bootstrap@127.0.0.1:5432/inventory?sslmode=disable", "-e", "MESH_STORE_IDENTITY=postgres://postgres:bootstrap@127.0.0.1:5432/identity?sslmode=disable", "-e", "MESH_STORE_LICENCES=postgres://postgres:bootstrap@127.0.0.1:5432/licences?sslmode=disable", - "192.0.2.250:5000/mesh-control@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace", + "192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace", "migrate"], "verify": ["sh", "-c", "docker exec mesh-store psql -U postgres -d inventory -tAc \"select to_regclass('public.node')\" | grep -qx node && docker exec mesh-store psql -U postgres -d identity -tAc \"select to_regclass('public.signing_key')\" | grep -qx signing_key && docker exec mesh-store psql -U postgres -d licences -tAc \"select to_regclass('public.licence')\" | grep -qx licence"] }, @@ -133,8 +133,8 @@ { "id": "control-plane", "type": "container", - "name": "mesh-control", - "image": "192.0.2.250:5000/mesh-control@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace", + "name": "mesh-controller", + "image": "192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace", "network": "host", "args": ["serve"], "volumes": ["mesh-broker-tls:/broker-tls:ro"], diff --git a/internal/apply/apply_test.go b/internal/apply/apply_test.go index 0086be6..3dfc524 100644 --- a/internal/apply/apply_test.go +++ b/internal/apply/apply_test.go @@ -459,7 +459,7 @@ func TestForgettingAUnitThatIsGoneDoesNotStrandTheNode(t *testing.T) { } } -// --- package, container and action (novox/hq 07-the-substrate.md, ADR 0006, ADR 0005) --- +// --- package, container and action (novox/hq 07-the-foundation.md, ADR 0006, ADR 0005) --- func parseTrusted(t *testing.T, raw string) *declaration.Declaration { t.Helper() diff --git a/internal/bootstrap/apply.go b/internal/bootstrap/apply.go index 6f8e7c0..372793d 100644 --- a/internal/bootstrap/apply.go +++ b/internal/bootstrap/apply.go @@ -15,7 +15,7 @@ import ( // Runner is the same runner every applier in this repository takes. type Runner = apply.Runner -// ApplyBundle raises the substrate, through the host's own apply. +// ApplyBundle raises the foundation, through the host's own apply. // // **This calls `internal/apply` rather than running the `mesh-host` binary**, and that is worth // stating because shelling out would have been easier. The installer and the host must apply a @@ -25,7 +25,7 @@ type Runner = apply.Runner // raising, which would make the installer depend on the thing it installs. // // It applies under `store.OriginCarried`, which is the same origin `mesh-host reconcile` uses and -// is not a detail: what the substrate raised must be invisible to the removal pass of a +// is not a detail: what the foundation raised must be invisible to the removal pass of a // declaration that later arrives from the control plane, or the first thing the mesh tells this // node would tear down the mesh (novox/hq 04-ISSUES/010). // @@ -71,12 +71,12 @@ func ApplyBundle(ctx context.Context, o Options, sys system.System, d *declarati // // It cannot happen and it is refused with a sentence rather than a nil dereference. A sealing key // is generated at enrolment (`internal/identity`), and enrolment is something that happens on a -// mesh — which is the thing this program is raising. A substrate bundle carrying a sealed file +// mesh — which is the thing this program is raising. A foundation bundle carrying a sealed file // would be a bundle written for a node that has already joined. func refuseSealed(string) ([]byte, error) { return nil, errors.New( "this bundle contains a file sealed to a node's key, and a machine that has not enrolled " + - "has no such key. A substrate is applied before any mesh exists, so it can carry no " + + "has no such key. A foundation is applied before any mesh exists, so it can carry no " + "secret the mesh sealed") } diff --git a/internal/bootstrap/apply_test.go b/internal/bootstrap/apply_test.go index 7a3c8ea..e3f45e6 100644 --- a/internal/bootstrap/apply_test.go +++ b/internal/bootstrap/apply_test.go @@ -78,12 +78,12 @@ func TestASystemNobodyHasBuiltIsRefusedByName(t *testing.T) { } } -// A substrate is applied before any mesh exists, so it can carry no secret the mesh sealed — there +// A foundation is applied before any mesh exists, so it can carry no secret the mesh sealed — there // is no key to open one with. Refused with a sentence rather than a nil dereference. -func TestASealedFileInASubstrateIsRefusedWithAReason(t *testing.T) { +func TestASealedFileInAFoundationIsRefusedWithAReason(t *testing.T) { _, err := refuseSealed("anything") if err == nil { - t.Fatal("a sealed file in a substrate bundle was accepted") + t.Fatal("a sealed file in a foundation bundle was accepted") } if !strings.Contains(err.Error(), "has not enrolled") { t.Errorf("the refusal does not say why there is no key: %v", err) diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index b633144..e65496b 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -111,7 +111,7 @@ func failed(step Step, err error) error { // Options are the things that differ between machines. type Options struct { - // Template is the substrate bundle this machine's own bundle is made from. + // Template is the foundation bundle this machine's own bundle is made from. Template string // Out is where the produced bundle is written, so a person can read what was applied. Out string @@ -128,12 +128,12 @@ type Options struct { // runtime, a control plane opening its stores. Wait time.Duration - // Node is the name this machine is known by in the mesh. Everything after the substrate names + // Node is the name this machine is known by in the mesh. Everything after the foundation names // it: the record, the token, the assignment, the push. Node string // Catalogue is a checkout of the mesh's catalogue repository, which is where the registry's and - // the control plane's manifests are read from. Empty stops the installer after the substrate: + // the control plane's manifests are read from. Empty stops the installer after the foundation: // there is no pivot without manifests, and pretending otherwise would leave a machine that // looks installed and cannot upgrade itself. Catalogue string @@ -181,7 +181,7 @@ type Options struct { Extras []string } -// pivots reports whether this run goes past the substrate. +// pivots reports whether this run goes past the foundation. func (o Options) pivots() bool { return strings.TrimSpace(o.Catalogue) != "" } // Deps are the ways this program reaches outside itself. Injected so the whole of it can be @@ -245,11 +245,11 @@ type Result struct { Applied int `json:"applied,omitempty"` Changed bool `json:"changed,omitempty"` - // Running is the substrate's containers, confirmed up. + // Running is the foundation's containers, confirmed up. Running []string `json:"running,omitempty"` // Answered is what the temporary control plane said back — not merely that it is up. Answered string `json:"temporary-control-plane,omitempty"` - // Temporary is what the substrate's control plane is called, which is not what the module's is. + // Temporary is what the foundation's control plane is called, which is not what the module's is. Temporary string `json:"temporary-container,omitempty"` // Node is this machine's name in the mesh, and how it came to be enrolled and heard from. @@ -289,15 +289,15 @@ type Result struct { // answer to it is to run this again: re-running is the retry, and it is one a person chooses after // reading which step failed and why. // -// **Genesis is a pivot** (novox/hq ADR 0067). Steps 1 to 5 raise a substrate whose control plane is +// **Genesis is a pivot** (novox/hq ADR 0067). Steps 1 to 5 raise a foundation whose control plane is // named by the digest of its own configuration, because nothing has ever served that image and // nothing could have. Steps 6 to 10 turn that into a mesh that can maintain itself: this machine // enrols, the registry module is installed, the carried image is pushed INTO that registry — which // gives it a manifest digest, its first — and the control plane is reinstalled as an ordinary // module pinned to it. The temporary one is then dropped from the bundle and the host removes it. // -// **What makes the last part expressible is a name.** The substrate's control plane is called -// `temp-mesh-control` and the module's is called `mesh-control`. Two containers, two owners: +// **What makes the last part expressible is a name.** The foundation's control plane is called +// `temp-mesh-controller` and the module's is called `mesh-controller`. Two containers, two owners: // nothing is handed over, nothing has to stop being owned without being destroyed, and destruction // by omission is the right end for something named "temp". // @@ -309,7 +309,7 @@ type Result struct { // be fixed remotely — so no step may leave one: // // 1–3 nothing on the machine but a written file. Re-run: the bundle is produced again. -// 4 a partly-raised substrate, recorded in the state file. Re-run: apply converges the rest. +// 4 a partly-raised foundation, recorded in the state file. Re-run: apply converges the rest. // 5 everything up; something did not answer yet. Re-run: it is asked again. // 6 a node record and possibly a spent token. Re-run: `node list` finds the record, the // identity file says whether this machine enrolled, and a fresh token is issued if not. @@ -458,7 +458,7 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro o.Out, rewritten.Resources)) // ---- 4. apply ----------------------------------------------------------------------- - say("apply — raising the substrate") + say("apply — raising the foundation") report, err := ApplyBundle(ctx, o, sys, rewritten.Declaration, d.Run, say) result.Applied, result.Changed = len(report.Outcomes), report.Changed() if err != nil { @@ -472,7 +472,7 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro } // ---- 5. verify ---------------------------------------------------------------------- - say("verify — the substrate is up, and the control plane replies") + say("verify — the foundation is up, and the control plane replies") verified, err := Verify(ctx, rewritten.Declaration, d.Run, o.Timeout, o.Wait, say) result.Running, result.Answered = verified.Running, verified.Answered if err != nil { @@ -484,7 +484,7 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro // control plane is named by an image id, which no registry serves, so nothing can ever // replace it with a newer one. That is the whole of what the pivot fixes, and it needs // manifests, and manifests come from a checkout somebody has to point this at. - result.Stopped = "no --catalog was given, so this stopped at the substrate. " + + result.Stopped = "no --catalog was given, so this stopped at the foundation. " + "The control plane is named by the digest of its own configuration and no registry " + "serves it, so this mesh cannot yet upgrade itself. Run again with " + "--catalog to finish the pivot; every step " + @@ -497,7 +497,7 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro // ---- 6. enrol ------------------------------------------------------------------------- // // From here on the mesh is being told things, and the way to tell it anything is to run its - // own binary inside its own container. `temporary` is the substrate's control plane; the + // own binary inside its own container. `temporary` is the foundation's control plane; the // module's is a different container with a different name and does not exist yet. temporary := controlPlane{container: rewritten.TempName, run: d.Run, timeout: o.Timeout} @@ -588,9 +588,9 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro } // ---- 14. store ------------------------------------------------------------------------ - // A database PROVIDER. The substrate's store is the control plane's own memory and offers + // A database PROVIDER. The foundation'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") + say("store — a database provider, which the foundation's own store is not") if err := InstallFromCatalogue(ctx, o, permanentControl, "postgres", say); err != nil { return result, failed(StepStore, err) } diff --git a/internal/bootstrap/build_test.go b/internal/bootstrap/build_test.go index a62ecf2..9abd359 100644 --- a/internal/bootstrap/build_test.go +++ b/internal/bootstrap/build_test.go @@ -24,7 +24,7 @@ func TestAnInstallerWithNothingToBuildRefuses(t *testing.T) { // A repository without a commit refuses too, because a branch is somebody else's moving target. func TestABranchIsNotACommit(t *testing.T) { - err := Source{Repository: "https://example.invalid/mesh-control.git"}.Check() + err := Source{Repository: "https://example.invalid/mesh-controller.git"}.Check() if err == nil { t.Fatal("a source with no ref was accepted; genesis would have built whatever a branch pointed at") } @@ -35,7 +35,7 @@ func TestABranchIsNotACommit(t *testing.T) { // And a repository with a commit is enough. func TestARepositoryAndACommitIsEnough(t *testing.T) { - if err := (Source{Repository: "https://example.invalid/mesh-control.git", Ref: "a1b2c3d4"}).Check(); err != nil { + if err := (Source{Repository: "https://example.invalid/mesh-controller.git", Ref: "a1b2c3d4"}).Check(); err != nil { t.Fatalf("a repository and a commit were refused: %v", err) } } diff --git a/internal/bootstrap/control.go b/internal/bootstrap/control.go index 6f6d937..e7a655a 100644 --- a/internal/bootstrap/control.go +++ b/internal/bootstrap/control.go @@ -14,7 +14,7 @@ import ( // storeFileSuffix is how a manifest asks for a store connection in a file rather than in the // environment. // -// `MESH_STORE_` is what the control plane reads (mesh-control's `internal/store`.Variable) +// `MESH_STORE_` is what the control plane reads (mesh-controller's `internal/store`.Variable) // and putting a password in a container's environment puts it in `docker inspect` for ever. So a // module manifest names a file per context and points at it with `…_FILE`; the mesh seals the value // into that file on the machine, and nothing but the process reads it. @@ -41,20 +41,20 @@ type Permanent struct { // **The host performs the replacement, not the control plane** (novox/hq ADR 0067). The temporary // control plane composes a declaration naming the registry-pinned image, publishes it, and this // node's host creates the container. Nothing is asked to replace itself while running, which is -// what makes the whole thing expressible: the container being created is called `mesh-control` and -// the one composing it is called `temp-mesh-control`, so there are two of them and neither is in +// what makes the whole thing expressible: the container being created is called `mesh-controller` and +// the one composing it is called `temp-mesh-controller`, so there are two of them and neither is in // the other's way. // -// **The store connections are the substrate's, made at genesis, and the mesh cannot invent them.** +// **The store connections are the foundation's, made at genesis, and the mesh cannot invent them.** // Every other secret in a mesh is one the mesh made; these existed before the mesh did — they are -// the credentials the substrate bundle created the databases with. Generating replacements would +// the credentials the foundation bundle created the databases with. Generating replacements would // put thirty-two random bytes where a working connection string has to be, and the control plane // would come up unable to open a single context. So they go in through `secret accept`, which is // exactly the path for a value the mesh must carry and could not have invented — and they are read // out of the bundle this installer produced rather than reconstructed, because the bundle is what // created them and a second opinion about what a DSN should say is a second chance to be wrong. func InstallControlPlane(ctx context.Context, o Options, d Deps, control controlPlane, - substrate *declaration.Declaration, image string, say func(string)) (Permanent, error) { + foundation *declaration.Declaration, image string, say func(string)) (Permanent, error) { out := Permanent{Image: image} @@ -63,7 +63,7 @@ func InstallControlPlane(ctx context.Context, o Options, d Deps, control control return out, fmt.Errorf( "%w\n"+ "This is the manifest that makes the control plane an ordinary module. Without it "+ - "the machine keeps the temporary control plane the substrate raised, which works "+ + "the machine keeps the temporary control plane the foundation raised, which works "+ "and cannot be upgraded — so the install stops here rather than pretending to "+ "have pivoted", err) } @@ -92,7 +92,7 @@ func InstallControlPlane(ctx context.Context, o Options, d Deps, control control } // The connections, before the push that would otherwise deliver random bytes for them. - delivered, err := deliverStores(ctx, o, control, pinned, substrate, say) + delivered, err := deliverStores(ctx, o, control, pinned, foundation, say) out.Delivered = delivered if err != nil { return out, err @@ -107,7 +107,7 @@ func InstallControlPlane(ctx context.Context, o Options, d Deps, control control } // And it answers, which is the same question step 5 asked of the temporary one and for the // same reason: `status` opens all three stores, so a reply proves the sealed connections it - // was given are the ones the substrate made. Asked of the NEW container — this is the only + // was given are the ones the foundation made. Asked of the NEW container — this is the only // moment in the program where two control planes are running, and asking the wrong one would // report the temporary one's health as the permanent one's. answered, err := waitForTheControlPlane(ctx, control.run, o.Timeout, o.Wait, container, say) @@ -142,7 +142,7 @@ func pinImage(manifest []byte, reference string) ([]byte, int, error) { } // The reference the registry gave back is `/@sha256:…`, and what the // manifest holds is `@sha256:0…0`. Replacing only the digest would leave the - // manifest's own repository name in front of it — which may be `mesh-control` with no + // manifest's own repository name in front of it — which may be `mesh-controller` with no // registry, and a runtime would then pull it from the internet. The whole reference moves. var out bytes.Buffer rest := manifest @@ -215,21 +215,21 @@ func controlPlaneResourceIn(manifest []byte) string { return "" } -// deliverStores carries the substrate's own database connections into the module. +// deliverStores carries the foundation's own database connections into the module. // // The pairing is read from the manifest rather than assumed, so that whatever the catalogue calls // these secrets is what is delivered. The installer does not guess that the secret holding the // inventory connection is called `inventory`; it follows the manifest from the variable to the // secret, and a manifest whose two ends do not meet is refused rather than half-delivered. // -// **What is delivered is what the substrate already has, and only that.** The mesh generates an +// **What is delivered is what the foundation already has, and only that.** The mesh generates an // own-secret nobody supplied, which is right for something coming into existence and wrong for // something that already exists. So every variable the module fills from a secret is looked up in -// the substrate's control plane: what it names is accepted, what it does not is left for the mesh -// to make. A store connection missing from the substrate is the one exception and is an error — +// the foundation's control plane: what it names is accepted, what it does not is left for the mesh +// to make. A store connection missing from the foundation is the one exception and is an error — // a control plane that cannot open a context is not a control plane. func deliverStores(ctx context.Context, o Options, control controlPlane, manifest []byte, - substrate *declaration.Declaration, say func(string)) ([]string, error) { + foundation *declaration.Declaration, say func(string)) ([]string, error) { wanted, err := secretsByVariableIn(manifest) if err != nil { @@ -246,7 +246,7 @@ func deliverStores(ctx context.Context, o Options, control controlPlane, manifes ControlPlaneModule, storeVariablePrefix, storeVariablePrefix, storeFileSuffix) } - temporary, err := controlPlaneIn(substrate) + temporary, err := controlPlaneIn(foundation) if err != nil { return nil, err } @@ -260,13 +260,13 @@ func deliverStores(ctx context.Context, o Options, control controlPlane, manifes return delivered, fmt.Errorf( "the %s module wants %s and the bundle this installer produced does not name "+ "one.\n"+ - "That connection is the substrate's, created at genesis — the mesh cannot "+ + "That connection is the foundation's, created at genesis — the mesh cannot "+ "invent it and the installer will not guess at one", ControlPlaneModule, variable) } - // Not something the substrate made. The mesh generates its own, which is exactly what + // Not something the foundation made. The mesh generates its own, which is exactly what // an own-secret is for; said so that nothing about the delivery is silent. - say(" the mesh will make " + secret + " — the substrate names no " + variable) + say(" the mesh will make " + secret + " — the foundation names no " + variable) continue } @@ -282,7 +282,7 @@ func deliverStores(ctx context.Context, o Options, control controlPlane, manifes return delivered, err } delivered = append(delivered, secret) - say(" accepted " + secret + " — " + variable + ", as the substrate made it") + say(" accepted " + secret + " — " + variable + ", as the foundation made it") } return delivered, nil } diff --git a/internal/bootstrap/control_test.go b/internal/bootstrap/control_test.go index 9a6a5e1..e4b0d6f 100644 --- a/internal/bootstrap/control_test.go +++ b/internal/bootstrap/control_test.go @@ -9,37 +9,37 @@ import ( // Step 9 is where the control plane stops being a special case. These tests defend the two things // that could go wrong quietly: pinning it to the wrong image, and delivering it store connections -// the mesh invented rather than the ones the substrate actually made. +// the mesh invented rather than the ones the foundation actually made. // theControlPlaneModule is the catalogue's manifest, trimmed to what this installer reads. // -// A fixture rather than the file itself, unlike the substrate example the rewrite tests use: the +// A fixture rather than the file itself, unlike the foundation example the rewrite tests use: the // catalogue is a different repository on a different branch, and a test that read it would pass or // fail according to what somebody else had checked out. What it must stay faithful to is the // SHAPE — the placeholder digest, the own-secret per context, the mount from the machine's path to // the container's, and the environment file that fills what is not a path. const theControlPlaneModule = `{ - "module": "mesh-control", + "module": "mesh-controller", "version": "1", "slug": "control", "capabilities": ["container-runtime"], - "claims": [{"name": "the-control-plane", "scope": "mesh"}], + "claims": [{"name": "the-controller", "scope": "mesh"}], "own-secrets": { - "inventory": "/var/lib/mesh/mesh-control/inventory", - "identity": "/var/lib/mesh/mesh-control/identity", - "licences": "/var/lib/mesh/mesh-control/licences", - "broker": "/var/lib/mesh/mesh-control/broker", - "broker-management": "/var/lib/mesh/mesh-control/broker-management" + "inventory": "/var/lib/mesh/mesh-controller/inventory", + "identity": "/var/lib/mesh/mesh-controller/identity", + "licences": "/var/lib/mesh/mesh-controller/licences", + "broker": "/var/lib/mesh/mesh-controller/broker", + "broker-management": "/var/lib/mesh/mesh-controller/broker-management" }, "resources": [ - {"id": "mesh-state", "type": "directory", "path": "/var/lib/mesh/mesh-control", "mode": "0700"}, - {"id": "broker-env", "type": "file", "path": "/var/lib/mesh/mesh-control/broker.env", + {"id": "mesh-state", "type": "directory", "path": "/var/lib/mesh/mesh-controller", "mode": "0700"}, + {"id": "broker-env", "type": "file", "path": "/var/lib/mesh/mesh-controller/broker.env", "mode": "0600", "content": "MESH_BROKER_AMQP=${secret:broker}\nMESH_BROKER_MANAGEMENT=${secret:broker-management}\nMESH_BROKER_ADDRESS=${machine:at}:5671\n"}, - {"id": "server", "type": "container", "name": "mesh-control", - "image": "mesh-control@` + placeholderDigest + `", + {"id": "server", "type": "container", "name": "mesh-controller", + "image": "mesh-controller@` + placeholderDigest + `", "network": "host", "args": ["serve"], - "env-file": ["/var/lib/mesh/mesh-control/broker.env"], + "env-file": ["/var/lib/mesh/mesh-controller/broker.env"], "env": { "MESH_STORE_INVENTORY_FILE": "/run/secrets/inventory", "MESH_STORE_IDENTITY_FILE": "/run/secrets/identity", @@ -48,18 +48,18 @@ const theControlPlaneModule = `{ }, "volumes": [ "mesh-broker-tls:/broker-tls:ro", - "/var/lib/mesh/mesh-control/inventory:/run/secrets/inventory:ro", - "/var/lib/mesh/mesh-control/identity:/run/secrets/identity:ro", - "/var/lib/mesh/mesh-control/licences:/run/secrets/licences:ro" + "/var/lib/mesh/mesh-controller/inventory:/run/secrets/inventory:ro", + "/var/lib/mesh/mesh-controller/identity:/run/secrets/identity:ro", + "/var/lib/mesh/mesh-controller/licences:/run/secrets/licences:ro" ]} ] }` -const pushedReference = "127.0.0.1:5000/mesh-control@sha256:" + +const pushedReference = "127.0.0.1:5000/mesh-controller@sha256:" + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" // **The whole reference moves, not only the digest.** The manifest's placeholder names a -// repository too, and replacing sixty-four zeros inside it would leave `mesh-control@sha256:…` +// repository too, and replacing sixty-four zeros inside it would leave `mesh-controller@sha256:…` // with no registry in front — which a runtime would go to the internet for, and this mesh's // control plane exists in no public registry by design. func TestTheControlPlaneIsPinnedToWhatThisMeshsRegistryAssigned(t *testing.T) { @@ -73,7 +73,7 @@ func TestTheControlPlaneIsPinnedToWhatThisMeshsRegistryAssigned(t *testing.T) { if !strings.Contains(string(pinned), `"image": "`+pushedReference+`"`) { t.Errorf("the manifest does not name the pushed image:\n%s", pinned) } - if strings.Contains(string(pinned), `"mesh-control@sha256:`) { + if strings.Contains(string(pinned), `"mesh-controller@sha256:`) { t.Errorf("the digest was replaced and the manifest's own repository name was left in "+ "front of it, so nothing says which registry serves it:\n%s", pinned) } @@ -95,10 +95,10 @@ func TestAManifestAlreadyPinnedByHandIsRefused(t *testing.T) { // otherwise be left half pinned, and fail inside an apply rather than here. func TestEveryPlaceTheManifestNamesTheImageIsPinned(t *testing.T) { twice := strings.Replace(theControlPlaneModule, - `{"id": "mesh-state", "type": "directory", "path": "/var/lib/mesh/mesh-control", "mode": "0700"},`, - `{"id": "mesh-state", "type": "directory", "path": "/var/lib/mesh/mesh-control", "mode": "0700"}, - {"id": "migrate", "type": "container", "name": "mesh-control-migrate", "run-once": true, - "image": "mesh-control@`+placeholderDigest+`", "args": ["migrate"]},`, 1) + `{"id": "mesh-state", "type": "directory", "path": "/var/lib/mesh/mesh-controller", "mode": "0700"},`, + `{"id": "mesh-state", "type": "directory", "path": "/var/lib/mesh/mesh-controller", "mode": "0700"}, + {"id": "migrate", "type": "container", "name": "mesh-controller-migrate", "run-once": true, + "image": "mesh-controller@`+placeholderDigest+`", "args": ["migrate"]},`, 1) pinned, places, err := pinImage([]byte(twice), pushedReference) if err != nil { @@ -112,8 +112,8 @@ func TestEveryPlaceTheManifestNamesTheImageIsPinned(t *testing.T) { } } -// **The connections are the substrate's, and they are read out of the bundle that made them.** -// The mesh cannot invent them: they are the credentials the substrate created the databases with, +// **The connections are the foundation's, and they are read out of the bundle that made them.** +// The mesh cannot invent them: they are the credentials the foundation created the databases with, // and thirty-two random bytes in their place would leave the control plane unable to open a single // context. The pairing is read from the manifest so that whatever the catalogue calls these // secrets is what is delivered. @@ -141,38 +141,38 @@ func TestTheStoreConnectionsComeFromTheBundleThatMadeThem(t *testing.T) { t.Error("the address the mesh composes from the machine was treated as a secret") } - // The values are the substrate's own, taken from the produced bundle rather than composed. + // The values are the foundation's own, taken from the produced bundle rather than composed. rewritten, err := Rewrite(theRealBundle(t), held) if err != nil { t.Fatal(err) } runtime := &asked{answer: aMeshThatAgrees(nil)} - control := controlPlane{container: "temp-mesh-control", run: runtime.run, timeout: time.Second} + control := controlPlane{container: "temp-mesh-controller", run: runtime.run, timeout: time.Second} delivered, err := deliverStores(context.Background(), Options{Node: "anchor"}, control, []byte(theControlPlaneModule), rewritten.Declaration, func(string) {}) if err != nil { t.Fatal(err) } - // Three stores and both halves of the broker: everything the substrate made and nothing else. + // Three stores and both halves of the broker: everything the foundation made and nothing else. if len(delivered) != 5 { - t.Fatalf("%d values were delivered, and the substrate names five: %v", + t.Fatalf("%d values were delivered, and the foundation names five: %v", len(delivered), delivered) } for _, secret := range delivered { - if !runtime.ran("secret accept anchor mesh-control " + secret + " --from") { + if !runtime.ran("secret accept anchor mesh-controller " + secret + " --from") { t.Errorf("%s was not accepted through `secret accept`: %v", secret, runtime.commands) } } } -// A secret the substrate did not make is left for the mesh to make, and said so. Every other +// A secret the foundation did not make is left for the mesh to make, and said so. Every other // secret in a mesh is one the mesh made; `secret accept` is only for what predates the mesh. -func TestASecretTheSubstrateNeverMadeIsLeftToTheMesh(t *testing.T) { +func TestASecretTheFoundationNeverMadeIsLeftToTheMesh(t *testing.T) { extra := strings.Replace(theControlPlaneModule, - `"broker": "/var/lib/mesh/mesh-control/broker",`, - `"broker": "/var/lib/mesh/mesh-control/broker", - "something-new": "/var/lib/mesh/mesh-control/something-new",`, 1) + `"broker": "/var/lib/mesh/mesh-controller/broker",`, + `"broker": "/var/lib/mesh/mesh-controller/broker", + "something-new": "/var/lib/mesh/mesh-controller/something-new",`, 1) extra = strings.Replace(extra, `"content": "MESH_BROKER_AMQP=${secret:broker}\n`, `"content": "MESH_SOMETHING_NEW=${secret:something-new}\nMESH_BROKER_AMQP=${secret:broker}\n`, 1) @@ -182,7 +182,7 @@ func TestASecretTheSubstrateNeverMadeIsLeftToTheMesh(t *testing.T) { t.Fatal(err) } runtime := &asked{answer: aMeshThatAgrees(nil)} - control := controlPlane{container: "temp-mesh-control", run: runtime.run, timeout: time.Second} + control := controlPlane{container: "temp-mesh-controller", run: runtime.run, timeout: time.Second} var said []string delivered, err := deliverStores(context.Background(), Options{Node: "anchor"}, control, @@ -192,7 +192,7 @@ func TestASecretTheSubstrateNeverMadeIsLeftToTheMesh(t *testing.T) { } for _, secret := range delivered { if secret == "something-new" { - t.Error("a value the substrate never made was accepted as though it had") + t.Error("a value the foundation never made was accepted as though it had") } } if !strings.Contains(strings.Join(said, "\n"), "the mesh will make something-new") { @@ -205,8 +205,8 @@ func TestASecretTheSubstrateNeverMadeIsLeftToTheMesh(t *testing.T) { // be — which presents as a control plane that will not start, three steps from the cause. func TestAConnectionFileNothingWritesIsRefused(t *testing.T) { mismatched := strings.Replace(theControlPlaneModule, - `"inventory": "/var/lib/mesh/mesh-control/inventory",`, - `"inventory": "/var/lib/mesh/mesh-control/somewhere-else",`, 1) + `"inventory": "/var/lib/mesh/mesh-controller/inventory",`, + `"inventory": "/var/lib/mesh/mesh-controller/somewhere-else",`, 1) _, err := secretsByVariableIn([]byte(mismatched)) if err == nil { @@ -226,11 +226,11 @@ func TestAManifestWantingNoStoresIsRefusedWithTheShapeItShouldHave(t *testing.T) t.Fatal(err) } runtime := &asked{answer: aMeshThatAgrees(nil)} - control := controlPlane{container: "temp-mesh-control", run: runtime.run, timeout: time.Second} + control := controlPlane{container: "temp-mesh-controller", run: runtime.run, timeout: time.Second} - bare := `{"module":"mesh-control","version":"1","resources":[ - {"id":"container","type":"container","name":"mesh-control", - "image":"mesh-control@` + placeholderDigest + `"}]}` + bare := `{"module":"mesh-controller","version":"1","resources":[ + {"id":"container","type":"container","name":"mesh-controller", + "image":"mesh-controller@` + placeholderDigest + `"}]}` _, err = deliverStores(context.Background(), Options{Node: "anchor"}, control, []byte(bare), rewritten.Declaration, func(string) {}) @@ -244,13 +244,13 @@ func TestAManifestWantingNoStoresIsRefusedWithTheShapeItShouldHave(t *testing.T) // The permanent control plane is asked a question, not merely looked at — the same question the // temporary one was asked at step 5, and for the same reason: `status` opens all three stores, so -// a reply proves the sealed connections it was given are the ones the substrate made. +// a reply proves the sealed connections it was given are the ones the foundation made. func TestThePermanentControlPlaneIsAskedTheSameQuestion(t *testing.T) { runtime := &asked{answer: aMeshThatAgrees(map[string]string{ "module list": "", - "exec mesh-control /mesh-control": "1 node, 0 waiting\n", + "exec mesh-controller /mesh-controller": "1 node, 0 waiting\n", })} - control := controlPlane{container: "temp-mesh-control", run: runtime.run, timeout: time.Second} + control := controlPlane{container: "temp-mesh-controller", run: runtime.run, timeout: time.Second} rewritten, err := Rewrite(theRealBundle(t), held) if err != nil { t.Fatal(err) @@ -265,11 +265,11 @@ func TestThePermanentControlPlaneIsAskedTheSameQuestion(t *testing.T) { if out.Answered != "1 node, 0 waiting" { t.Errorf("the permanent control plane's reply is reported as %q", out.Answered) } - if !runtime.ran("docker exec mesh-control " + controlPlaneBinary + " status") { + if !runtime.ran("docker exec mesh-controller " + controlPlaneBinary + " status") { t.Errorf("the permanent control plane was never asked anything: %v", runtime.commands) } // And the module was registered with the digest, not with the placeholder. - if !runtime.ran("module add /mesh-control-module.json") { + if !runtime.ran("module add /mesh-controller-module.json") { t.Errorf("the module was never registered: %v", runtime.commands) } } diff --git a/internal/bootstrap/enrol.go b/internal/bootstrap/enrol.go index facbda5..10c4646 100644 --- a/internal/bootstrap/enrol.go +++ b/internal/bootstrap/enrol.go @@ -13,7 +13,7 @@ import ( // hereIs what `node list` says about a machine the mesh has heard from recently. // -// mesh-control prints one of three words per node: "here", "never spoken", or "out of touch ". +// mesh-controller prints one of three words per node: "here", "never spoken", or "out of touch ". // The installer waits for the first, and it is the only honest proof that the host agent is // running: an enrolled machine whose host is not running looks exactly like an enrolled machine // whose host has crashed, and both look exactly like a successful install until the first push diff --git a/internal/bootstrap/enrol_test.go b/internal/bootstrap/enrol_test.go index cad2fa9..48e71cb 100644 --- a/internal/bootstrap/enrol_test.go +++ b/internal/bootstrap/enrol_test.go @@ -70,7 +70,7 @@ func TestAMachineThatHasAlreadyEnrolledIsNotEnrolledAgain(t *testing.T) { out, err := Enrol(context.Background(), Options{ Node: "anchor", State: alreadyEnrolled(t, "anchor"), Timeout: time.Second, Host: "/usr/local/bin/mesh-host", HostInBackground: true, - }, arch(t), controlPlane{container: "temp-mesh-control", run: runtime.run, timeout: time.Second}, + }, arch(t), controlPlane{container: "temp-mesh-controller", run: runtime.run, timeout: time.Second}, func(string) {}) if err != nil { t.Fatal(err) @@ -109,7 +109,7 @@ func TestAMeshThatHasHeardFromAMachineWithNoAgentStartsOne(t *testing.T) { out, err := Enrol(context.Background(), Options{ Node: "anchor", State: alreadyEnrolled(t, "anchor"), Timeout: time.Second, Host: "/usr/local/bin/mesh-host", HostInBackground: true, - }, arch(t), controlPlane{container: "temp-mesh-control", run: runtime.run, timeout: time.Second}, + }, arch(t), controlPlane{container: "temp-mesh-controller", run: runtime.run, timeout: time.Second}, func(string) {}) if err != nil { t.Fatal(err) @@ -135,7 +135,7 @@ func TestAMachineEnrolledUnderAnotherNameIsRefused(t *testing.T) { _, err := Enrol(context.Background(), Options{ Node: "anchor", State: alreadyEnrolled(t, "somewhere-else"), Timeout: time.Second, - }, arch(t), controlPlane{container: "temp-mesh-control", run: runtime.run, timeout: time.Second}, + }, arch(t), controlPlane{container: "temp-mesh-controller", run: runtime.run, timeout: time.Second}, func(string) {}) if err == nil { t.Fatal("a machine already enrolled as something else was enrolled again") @@ -173,7 +173,7 @@ func TestAHostThatIsRunningAndUnheardOfIsNotAnInstall(t *testing.T) { _, err := Enrol(context.Background(), Options{ Node: "anchor", State: alreadyEnrolled(t, "anchor"), HostService: "mesh-host.service", Timeout: time.Second, Wait: 0, - }, arch(t), controlPlane{container: "temp-mesh-control", run: runtime.run, timeout: time.Second}, + }, arch(t), controlPlane{container: "temp-mesh-controller", run: runtime.run, timeout: time.Second}, func(string) {}) if err == nil { t.Fatal("a node the mesh has never heard from was reported enrolled and running") @@ -202,7 +202,7 @@ func TestAMachineWithNoHostServiceIsRefusedRatherThanGivenOne(t *testing.T) { _, err := Enrol(context.Background(), Options{ Node: "anchor", State: alreadyEnrolled(t, "anchor"), HostService: "mesh-host.service", Timeout: time.Second, Wait: 0, - }, arch(t), controlPlane{container: "temp-mesh-control", run: runtime.run, timeout: time.Second}, + }, arch(t), controlPlane{container: "temp-mesh-controller", run: runtime.run, timeout: time.Second}, func(string) {}) if err == nil { t.Fatal("a machine with no host service was reported as having a running host") @@ -222,7 +222,7 @@ func TestAMachineWithNoNameIsRefusedBeforeAnythingIsAsked(t *testing.T) { return "", fmt.Errorf("nothing should have been asked") }} _, err := Enrol(context.Background(), Options{Timeout: time.Second}, arch(t), - controlPlane{container: "temp-mesh-control", run: runtime.run}, func(string) {}) + controlPlane{container: "temp-mesh-controller", run: runtime.run}, func(string) {}) if err == nil { t.Fatal("a machine with no name was enrolled") } diff --git a/internal/bootstrap/load.go b/internal/bootstrap/load.go index ef6ca17..90f131f 100644 --- a/internal/bootstrap/load.go +++ b/internal/bootstrap/load.go @@ -120,7 +120,7 @@ func loadImage(ctx context.Context, run Runner, saved []byte, dryRun bool, say f // Through a file rather than through stdin: the runner this repository shares runs a command // and captures its output, and giving it a second mouth for one caller would change every // applier's contract for the sake of one step (internal/apply's Runner). - tarball, err := os.CreateTemp("", "mesh-control-*.tar") + tarball, err := os.CreateTemp("", "mesh-controller-*.tar") if err != nil { return loaded, fmt.Errorf("nowhere to put the carried image while loading it: %w", err) } diff --git a/internal/bootstrap/load_test.go b/internal/bootstrap/load_test.go index b37fdc2..2707603 100644 --- a/internal/bootstrap/load_test.go +++ b/internal/bootstrap/load_test.go @@ -41,12 +41,12 @@ func (a *asked) ran(fragment string) bool { return false } -// savedImageFixture builds what `docker save` produces, tagged `mesh-control:test` unless a test +// savedImageFixture builds what `docker save` produces, tagged `mesh-controller:test` unless a test // asks for something else. Pass no tags for an archive saved without one. func savedImageFixture(t *testing.T, digest string, tags ...string) []byte { t.Helper() if tags == nil { - tags = []string{"mesh-control:test"} + tags = []string{"mesh-controller:test"} } entries, err := json.Marshal([]struct { Config string @@ -75,7 +75,7 @@ func savedImageFixture(t *testing.T, digest string, tags ...string) []byte { // fixtureDigest is what the ARCHIVE calls the image, and runtimeDigest is what a runtime calls it // after loading the same bytes. They differ on purpose, because they differ in reality: an image // id is the digest of the image's configuration, and a runtime rewrites that configuration as it -// loads. Measured on a live raise, `mesh-control:development` was `sha256:b86bb81c…` on the +// loads. Measured on a live raise, `mesh-controller:development` was `sha256:b86bb81c…` on the // workstation that saved it and `sha256:2dc21904…` on the machine that loaded it. const ( fixtureDigest = "3333333333333333333333333333333333333333333333333333333333333333" @@ -107,7 +107,7 @@ func TestTheIdComesFromTheRuntimeAndNotFromTheArchive(t *testing.T) { // Loaded — and stored under a configuration of the runtime's own making. return "sha256:" + runtimeDigest + "\n", nil case len(args) > 0 && args[0] == "load": - return "Loaded image: mesh-control:test\n", nil + return "Loaded image: mesh-controller:test\n", nil } return "", fmt.Errorf("unexpected command: %v", args) } @@ -128,7 +128,7 @@ func TestTheIdComesFromTheRuntimeAndNotFromTheArchive(t *testing.T) { t.Error("a real run reported its id as a prediction") } // The runtime was asked BY THE TAG, which is the only name that survives the transfer. - if !runtime.ran("docker image inspect --format {{.Id}} mesh-control:test") { + if !runtime.ran("docker image inspect --format {{.Id}} mesh-controller:test") { t.Errorf("the runtime was never asked what the tag resolves to: %v", runtime.commands) } // And the difference is said out loud, or somebody comparing this against `docker images` on @@ -183,7 +183,7 @@ func TestALoadThatLeftNothingBehindIsAFailure(t *testing.T) { if len(args) > 1 && args[0] == "image" && args[1] == "inspect" { return "", errors.New("Error: No such image") } - return "Loaded image: mesh-control:test\n", nil + return "Loaded image: mesh-controller:test\n", nil }} _, err := loadImage(context.Background(), runtime.run, @@ -192,7 +192,7 @@ func TestALoadThatLeftNothingBehindIsAFailure(t *testing.T) { t.Fatal("a load that left nothing on the machine was reported as success") } // Named by the tag, because that is what was asked about and what is missing. - if !strings.Contains(err.Error(), "mesh-control:test") { + if !strings.Contains(err.Error(), "mesh-controller:test") { t.Errorf("the failure does not say what this machine holds nothing of: %v", err) } } @@ -298,7 +298,7 @@ func TestAnInstallerCarryingNoImageSaysSoRatherThanRaisingHalfAMesh(t *testing.T // check anything against, so it is checked where the refusal can say whose mistake it is. func TestARuntimeAnsweringSomethingThatIsNotAnImageIdIsRefused(t *testing.T) { for _, nonsense := range []string{ - "mesh-control:test", + "mesh-controller:test", "sha256:" + strings.Repeat("9", 63), "", } { diff --git a/internal/bootstrap/module.go b/internal/bootstrap/module.go index 2c6eb43..0802e90 100644 --- a/internal/bootstrap/module.go +++ b/internal/bootstrap/module.go @@ -80,7 +80,7 @@ func installModule(ctx context.Context, o Options, control controlPlane, module // Split out because one module needs something in between: the control plane's own store // connections have to be accepted before its declaration is composed, or the mesh would seal // thirty-two random bytes into the file it expects a connection string in and the container would -// come up unable to open anything (mesh-control's `secret accept`, and what it exists for). +// come up unable to open anything (mesh-controller's `secret accept`, and what it exists for). // // **`module add` is run every time and is not skipped when the module is already known.** It is an // upsert on the manifest, and the manifest is exactly what changes between runs — step 9 registers diff --git a/internal/bootstrap/phase_packages.go b/internal/bootstrap/phase_packages.go index 10be2b7..d7f24ca 100644 --- a/internal/bootstrap/phase_packages.go +++ b/internal/bootstrap/phase_packages.go @@ -14,7 +14,7 @@ import ( // The base (mesh-tools) resolves the SDK by version from the mesh's package registry rather than // cloning it from a git URL (novox/hq ADR 0076, issue 053). So the registry has to answer, and the // SDK has to be in it, before the base build runs. That is a pivot like the control plane's: gitea's -// SERVER is raised directly here, on the substrate's own postgres, and adopted as an ordinary module +// SERVER is raised directly here, on the foundation's own postgres, and adopted as an ordinary module // only after the base exists (which is what lets its provisioner image — built on the base — run). // // Nothing here is the steady state. It is the smallest set of acts that puts a working npm registry @@ -22,9 +22,9 @@ import ( // and the SDK published under it. The gitea MODULE, installed after the base, takes all of this over. const ( - // substrateStore is the substrate's postgres container — the mesh's own memory, raised from the + // foundationStore is the foundation's postgres container — the mesh's own memory, raised from the // bundle. gitea's bootstrap database lives here too, so a mesh runs one postgres (issue 051). - substrateStore = "mesh-store" + foundationStore = "mesh-store" // giteaBootstrap is the gitea server raised directly at genesis, before gitea is a module. giteaBootstrap = "mesh-gitea-server" // giteaImage is the same upstream image the gitea module runs, pinned identically so the module @@ -39,7 +39,7 @@ const ( // builderGiteaUser is the gitea account the builder publishes and pulls with at genesis. It is // the `as` the builder's static package binding names. builderGiteaUser = "mesh-builder" - // giteaDBRole/giteaDBName is gitea's own database in the substrate store. + // giteaDBRole/giteaDBName is gitea's own database in the foundation store. giteaDBRole = "mesh_gitea" giteaDBName = "mesh_gitea" // giteaPort is where the raised server answers on the machine. @@ -59,7 +59,7 @@ func RaisePackageRegistry(ctx context.Context, o Options, d Deps, control contro return err } - say(" seeding gitea's database in the substrate store") + say(" seeding gitea's database in the foundation store") if err := seedGiteaDatabase(ctx, run, o.Timeout, dbPassword, say); err != nil { return err } @@ -106,8 +106,8 @@ func RaisePackageRegistry(ctx context.Context, o Options, d Deps, control contro return nil } -// seedGiteaDatabase creates gitea's role and database inside the substrate postgres, the same way -// the substrate creates its own — psql run through the store container (the map's Route B). The role +// seedGiteaDatabase creates gitea's role and database inside the foundation postgres, the same way +// the foundation creates its own — psql run through the store container (the map's Route B). The role // is created before the database because the database is owned by it. Both are tolerant of already // existing, so a re-run changes nothing. func seedGiteaDatabase(ctx context.Context, run Runner, timeout time.Duration, password string, @@ -119,7 +119,7 @@ func seedGiteaDatabase(ctx context.Context, run Runner, timeout time.Duration, p // transaction and \gexec does not parse through -c. The password is base64url, so it carries no // quote or backslash to escape inside a SQL literal. psql := func(sql string) (string, error) { - return run(asking, "docker", "exec", substrateStore, "psql", "-U", "postgres", "-tAc", sql) + return run(asking, "docker", "exec", foundationStore, "psql", "-U", "postgres", "-tAc", sql) } // The role: create it, and if it is already there (create fails) reset its password so a re-run @@ -128,7 +128,7 @@ func seedGiteaDatabase(ctx context.Context, run Runner, timeout time.Duration, p if _, err := psql(create); err != nil { alter := fmt.Sprintf("ALTER ROLE %s LOGIN PASSWORD '%s'", giteaDBRole, password) if _, err := psql(alter); err != nil { - return fmt.Errorf("could not create gitea's role in %s: %w", substrateStore, err) + return fmt.Errorf("could not create gitea's role in %s: %w", foundationStore, err) } } @@ -136,17 +136,17 @@ func seedGiteaDatabase(ctx context.Context, run Runner, timeout time.Duration, p // second create is an error rather than a no-op. present, err := psql(fmt.Sprintf("SELECT 1 FROM pg_database WHERE datname='%s'", giteaDBName)) if err != nil { - return fmt.Errorf("could not check for gitea's database in %s: %w", substrateStore, err) + return fmt.Errorf("could not check for gitea's database in %s: %w", foundationStore, err) } if strings.TrimSpace(present) != "1" { if _, err := psql(fmt.Sprintf("CREATE DATABASE %s OWNER %s", giteaDBName, giteaDBRole)); err != nil { - return fmt.Errorf("could not create gitea's database in %s: %w", substrateStore, err) + return fmt.Errorf("could not create gitea's database in %s: %w", foundationStore, err) } } return nil } -// raiseGiteaServer starts the gitea server container against the substrate store. It joins the +// raiseGiteaServer starts the gitea server container against the foundation store. It joins the // store's network namespace so `127.0.0.1:5432` reaches postgres, and publishes its own port on the // machine so the builder and this installer can reach it. Started if absent, left alone if present. func raiseGiteaServer(ctx context.Context, run Runner, timeout time.Duration, dbPassword string, @@ -179,7 +179,7 @@ func raiseGiteaServer(ctx context.Context, run Runner, timeout time.Duration, db } args := append([]string{ "run", "-d", "--name", giteaBootstrap, - // Host network, like the control plane: it reaches the substrate store on the machine's + // Host network, like the control plane: it reaches the foundation store on the machine's // loopback (where the store publishes 5432) and answers on the machine's own 3000, which is // where mesh-bootstrap and the builder's build containers look for it. "--network", "host", diff --git a/internal/bootstrap/preflight.go b/internal/bootstrap/preflight.go index f6008a6..5275aeb 100644 --- a/internal/bootstrap/preflight.go +++ b/internal/bootstrap/preflight.go @@ -26,7 +26,7 @@ func Preflight(ctx context.Context, o Options, d Deps, say func(string)) ([]byte // 1. Does this installer carry what it claims to? // // Asked before the machine is touched, for the same reason `mesh-host bundle` exists: a host - // that carries no substrate must say so when somebody asks, not on a first node + // that carries no foundation must say so when somebody asks, not on a first node // (internal/bundle). An installer built without an image would otherwise get a machine as far // as a running store and a running broker and stop. if image.IsEmpty() { @@ -67,13 +67,13 @@ func Preflight(ctx context.Context, o Options, d Deps, say func(string)) ([]byte } say(fmt.Sprintf(" builder %s carried (the archive calls it %s)", tag, carriedID)) - // 2. Is the template there, and is it a substrate? + // 2. Is the template there, and is it a foundation? template, err := os.ReadFile(o.Template) if err != nil { return nil, fmt.Errorf( "the bundle template could not be read: %w\n"+ "It is what this machine will be asked to be, so there is nothing to do without "+ - "it. Point --bundle at one; mesh-host's examples/substrate-first-node.lock is "+ + "it. Point --bundle at one; mesh-host's examples/foundation-first-node.lock is "+ "the shape", err) } // Parsed here as well as at the rewrite, because a template that is not a declaration should @@ -120,7 +120,7 @@ func Preflight(ctx context.Context, o Options, d Deps, say func(string)) ([]byte // with the id of the image this installer carries. Whatever the slot held is therefore never // pulled, never fetched, and never reached; requiring it to be reachable refuses a correct // install because of a string that is about to be thrown away. Found on the first real run: the - // lab's template still carried `192.0.2.250:5000/mesh-control@…`, the address of a registry that + // lab's template still carried `192.0.2.250:5000/mesh-controller@…`, the address of a registry that // no longer exists, and preflight timed out dialling it. for _, host := range registriesIn(parsed) { dialing, cancel := context.WithTimeout(ctx, o.Timeout) diff --git a/internal/bootstrap/preflight_test.go b/internal/bootstrap/preflight_test.go index 1664da1..3321ef5 100644 --- a/internal/bootstrap/preflight_test.go +++ b/internal/bootstrap/preflight_test.go @@ -82,7 +82,7 @@ func TestOnlyTheRegistriesTheBundleNamesAreAskedAbout(t *testing.T) { strings.Repeat("7", 64) + `"}, {"id":"broker","type":"container","name":"mesh-broker","image":"192.0.2.250:5000/lavinmq@sha256:` + strings.Repeat("8", 64) + `"}, - {"id":"control-plane","type":"container","name":"mesh-control","image":"` + held + `"} + {"id":"control-plane","type":"container","name":"mesh-controller","image":"` + held + `"} ]}`)) if err != nil { t.Fatal(err) @@ -111,7 +111,7 @@ func TestTheControlPlanesOwnRegistryIsNeverAskedAbout(t *testing.T) { parsed, err := declaration.ParseFileTrusted([]byte(`{"declaration":1,"resources":[ {"id":"store","type":"container","name":"mesh-store","image":"postgres@sha256:` + strings.Repeat("7", 64) + `"}, - {"id":"control-plane","type":"container","name":"mesh-control","image":"192.0.2.250:5000/mesh-control@sha256:` + + {"id":"control-plane","type":"container","name":"mesh-controller","image":"192.0.2.250:5000/mesh-controller@sha256:` + strings.Repeat("8", 64) + `"} ]}`)) if err != nil { @@ -137,7 +137,7 @@ func TestWhereAnImageWouldBeFetchedFrom(t *testing.T) { {"postgres@sha256:" + strings.Repeat("a", 64), DefaultRegistry, true}, {"cloudamqp/lavinmq@sha256:" + strings.Repeat("a", 64), DefaultRegistry, true}, {"192.0.2.250:5000/postgres@sha256:" + strings.Repeat("a", 64), "192.0.2.250:5000", true}, - {"localhost/mesh-control@sha256:" + strings.Repeat("a", 64), "localhost:443", true}, + {"localhost/mesh-controller@sha256:" + strings.Repeat("a", 64), "localhost:443", true}, {"registry.example.com/a/b@sha256:" + strings.Repeat("a", 64), "registry.example.com:443", true}, // Held by this machine. Nothing serves it, and nothing can. {"sha256:" + strings.Repeat("a", 64), "", false}, diff --git a/internal/bootstrap/publish.go b/internal/bootstrap/publish.go index 5ad5af9..0b01ca7 100644 --- a/internal/bootstrap/publish.go +++ b/internal/bootstrap/publish.go @@ -9,19 +9,19 @@ import ( ) // ControlPlaneRepository is what the control plane's image is called in the mesh's own registry. -const ControlPlaneRepository = "mesh-control" +const ControlPlaneRepository = "mesh-controller" // genesisTag is the tag the first push uses. // // A tag is not a pin and is never what anything is deployed from — the digest the registry assigns -// is (novox/hq ADR 0006). This exists so a person reading `/v2/mesh-control/tags/list` can see +// is (novox/hq ADR 0006). This exists so a person reading `/v2/mesh-controller/tags/list` can see // which image this mesh started from, and so the push has something to name. Everything downstream // uses the digest that comes back. const genesisTag = "genesis" // Published is what step 8 did. type Published struct { - // Reference is `/mesh-control@sha256:…` — the first manifest digest this image has + // Reference is `/mesh-controller@sha256:…` — the first manifest digest this image has // ever had, and the thing that makes the control plane an ordinary module. Reference string // Tagged is where it was pushed, tag and all. @@ -34,7 +34,7 @@ type Published struct { // // **This is the pivot's hinge.** Every image must be pinned by digest, and a digest a pin can mean // is one a REGISTRY assigned when something was pushed to it. The control plane's image is built -// from source and pushed nowhere, so it has none — which is why the substrate names it by the +// from source and pushed nowhere, so it has none — which is why the foundation names it by the // digest of its own configuration, and why that is legal exactly where nothing could have served // one. The moment this push completes, that stops being true: the image has a manifest digest, so // the control plane can be named the way every other module is named, so the mesh can build and @@ -42,7 +42,7 @@ type Published struct { // upgrade itself, which is the check novox/hq ADR 0067 states: after installing, the running // control plane must be pinned by a digest the mesh's own registry assigned, not by an image id. // -// **It mirrors mesh-control's `internal/builder`.PublishImage rather than importing it.** Tag, +// **It mirrors mesh-controller's `internal/builder`.PublishImage rather than importing it.** Tag, // push, read back `RepoDigests`, refuse anything without `@sha256:` — the same four steps, because // there is exactly one right way to learn what a registry will serve something as, and it is to // ask the registry. It is not imported because that code is tier 2: the host and its installer diff --git a/internal/bootstrap/publish_test.go b/internal/bootstrap/publish_test.go index 759414d..c1b526d 100644 --- a/internal/bootstrap/publish_test.go +++ b/internal/bootstrap/publish_test.go @@ -41,7 +41,7 @@ func TestAnImageNoRegistryHasEverHeldIsPushed(t *testing.T) { if !pushed { return http.StatusNotFound, "", nil } - return http.StatusOK, `{"name":"mesh-control","tags":["genesis"]}`, nil + return http.StatusOK, `{"name":"mesh-controller","tags":["genesis"]}`, nil }, func(_ string, args []string) (string, error) { switch args[0] { @@ -51,7 +51,7 @@ func TestAnImageNoRegistryHasEverHeldIsPushed(t *testing.T) { pushed = true return "", nil case "inspect": - return `["127.0.0.1:5000/mesh-control@sha256:` + strings.Repeat("a", 64) + `"]`, nil + return `["127.0.0.1:5000/mesh-controller@sha256:` + strings.Repeat("a", 64) + `"]`, nil } return "", fmt.Errorf("unexpected: %v", args) }) @@ -63,10 +63,10 @@ func TestAnImageNoRegistryHasEverHeldIsPushed(t *testing.T) { if out.Already { t.Error("an image no registry held was reported as already published") } - if !strings.HasPrefix(out.Reference, "127.0.0.1:5000/mesh-control@sha256:") { + if !strings.HasPrefix(out.Reference, "127.0.0.1:5000/mesh-controller@sha256:") { t.Errorf("the control plane is pinned as %q", out.Reference) } - if !runtime.ran("docker push 127.0.0.1:5000/mesh-control:genesis") { + if !runtime.ran("docker push 127.0.0.1:5000/mesh-controller:genesis") { t.Errorf("nothing was pushed: %v", runtime.commands) } } @@ -77,11 +77,11 @@ func TestAnImageNoRegistryHasEverHeldIsPushed(t *testing.T) { func TestAnImageTheRegistryAlreadyServesIsNotPushedAgain(t *testing.T) { o, d, runtime := publishing(t, func(string) (int, string, error) { - return http.StatusOK, `{"name":"mesh-control","tags":["genesis"]}`, nil + return http.StatusOK, `{"name":"mesh-controller","tags":["genesis"]}`, nil }, func(_ string, args []string) (string, error) { if args[0] == "inspect" { - return `["127.0.0.1:5000/mesh-control@sha256:` + strings.Repeat("b", 64) + `"]`, nil + return `["127.0.0.1:5000/mesh-controller@sha256:` + strings.Repeat("b", 64) + `"]`, nil } return "", fmt.Errorf("unexpected: %v", args) }) @@ -103,8 +103,8 @@ func TestAnImageTheRegistryAlreadyServesIsNotPushedAgain(t *testing.T) { // listed first — which would pin this mesh's control plane to somebody else's registry, silently, // which is the dependency the whole pivot exists to remove. func TestTheDigestComesFromThisMeshsOwnRegistry(t *testing.T) { - elsewhere := "some.other.registry/mesh-control@sha256:" + strings.Repeat("c", 64) - ours := "127.0.0.1:5000/mesh-control@sha256:" + strings.Repeat("d", 64) + elsewhere := "some.other.registry/mesh-controller@sha256:" + strings.Repeat("c", 64) + ours := "127.0.0.1:5000/mesh-controller@sha256:" + strings.Repeat("d", 64) o, d, _ := publishing(t, func(string) (int, string, error) { @@ -167,7 +167,7 @@ func TestATagIsNotAPin(t *testing.T) { }, func(_ string, args []string) (string, error) { if args[0] == "inspect" { - return `["127.0.0.1:5000/mesh-control:genesis"]`, nil + return `["127.0.0.1:5000/mesh-controller:genesis"]`, nil } return "", nil }) diff --git a/internal/bootstrap/registry.go b/internal/bootstrap/registry.go index 2578473..359007f 100644 --- a/internal/bootstrap/registry.go +++ b/internal/bootstrap/registry.go @@ -44,7 +44,7 @@ type Registry struct { // // **No credentials, and that is deliberate.** The registry is reached over the mesh's own private // network, which is already the encrypted and authenticated thing; a second layer inside it would -// be certificates to issue and rotate for no property the first does not have (mesh-control's +// be certificates to issue and rotate for no property the first does not have (mesh-controller's // `internal/builder`, which pushes to it the same way). So there is nothing here to configure and // nothing to seal — which is also why step 8 can push without the mesh having issued anything. // @@ -136,7 +136,7 @@ func waitForTheRegistry(ctx context.Context, d Deps, o Options, say func(string) // waitForContainer waits for a container the mesh was asked to create to be running. // -// Unlike the substrate's own verify, this one waits: the mesh applies through a node's host, over +// Unlike the foundation's own verify, this one waits: the mesh applies through a node's host, over // the broker, asynchronously. A push that the control plane accepted has not yet happened on the // machine, and refusing on the first look would refuse every correct install. func waitForContainer(ctx context.Context, run Runner, probe, wait time.Duration, name string, diff --git a/internal/bootstrap/registry_test.go b/internal/bootstrap/registry_test.go index effae95..4d86d71 100644 --- a/internal/bootstrap/registry_test.go +++ b/internal/bootstrap/registry_test.go @@ -18,7 +18,7 @@ import ( // catalogueWith writes a fake catalogue checkout holding one module's manifest. // -// A fixture here rather than the real catalogue, unlike the substrate example the rewrite tests +// A fixture here rather than the real catalogue, unlike the foundation example the rewrite tests // use: the catalogue is a different repository on a different branch, and a test that read it // would pass or fail according to what somebody else had checked out. func catalogueWith(t *testing.T, module, manifest string) string { @@ -100,7 +100,7 @@ func TestARegistryContainerThatIsUpIsNotARegistryThatServes(t *testing.T) { _, err := InstallRegistry(context.Background(), installing(t, catalogueWith(t, RegistryModule, upstreamRegistryManifest)), - deps, controlPlane{container: "temp-mesh-control", run: runtime.run, timeout: time.Second}, + deps, controlPlane{container: "temp-mesh-controller", run: runtime.run, timeout: time.Second}, func(string) {}) if err == nil { t.Fatal("a registry whose container is up and which answers 500 was accepted") @@ -124,7 +124,7 @@ func TestARegistryThatAnswersIsAccepted(t *testing.T) { out, err := InstallRegistry(context.Background(), installing(t, catalogueWith(t, RegistryModule, upstreamRegistryManifest)), - deps, controlPlane{container: "temp-mesh-control", run: runtime.run, timeout: time.Second}, + deps, controlPlane{container: "temp-mesh-controller", run: runtime.run, timeout: time.Second}, func(string) {}) if err != nil { t.Fatal(err) @@ -159,7 +159,7 @@ func TestARegistryManifestThatWantsBuildingIsRefused(t *testing.T) { runtime := &asked{answer: aMeshThatAgrees(nil)} _, err := InstallRegistry(context.Background(), installing(t, catalogueWith(t, RegistryModule, wants)), - Deps{Run: runtime.run}, controlPlane{container: "temp-mesh-control", run: runtime.run}, + Deps{Run: runtime.run}, controlPlane{container: "temp-mesh-controller", run: runtime.run}, func(string) {}) if err == nil { t.Fatal("a registry manifest naming an image the mesh would have to build was accepted") @@ -178,7 +178,7 @@ func TestACatalogueThatIsNotThereIsSaidPlainly(t *testing.T) { runtime := &asked{answer: aMeshThatAgrees(nil)} _, err := InstallRegistry(context.Background(), installing(t, filepath.Join(t.TempDir(), "nowhere")), - Deps{Run: runtime.run}, controlPlane{container: "temp-mesh-control", run: runtime.run}, + Deps{Run: runtime.run}, controlPlane{container: "temp-mesh-controller", run: runtime.run}, func(string) {}) if err == nil { t.Fatal("a catalogue that does not exist was accepted") @@ -188,7 +188,7 @@ func TestACatalogueThatIsNotThereIsSaidPlainly(t *testing.T) { } } -// A refusal from the control plane is repeated verbatim. mesh-control refuses in paragraphs — +// A refusal from the control plane is repeated verbatim. mesh-controller refuses in paragraphs — // "nothing provides route, wanted by registry" — and an installer that reported "exit status 1" // would throw away the only thing a person can act on. func TestWhatTheMeshRefusedIsRepeated(t *testing.T) { @@ -202,7 +202,7 @@ func TestWhatTheMeshRefusedIsRepeated(t *testing.T) { _, err := InstallRegistry(context.Background(), installing(t, catalogueWith(t, RegistryModule, upstreamRegistryManifest)), - Deps{Run: runtime.run}, controlPlane{container: "temp-mesh-control", run: runtime.run, + Deps{Run: runtime.run}, controlPlane{container: "temp-mesh-controller", run: runtime.run, timeout: time.Second}, func(string) {}) if err == nil { t.Fatal("a push the mesh refused was reported as successful") diff --git a/internal/bootstrap/retire.go b/internal/bootstrap/retire.go index a84055d..2ed13f8 100644 --- a/internal/bootstrap/retire.go +++ b/internal/bootstrap/retire.go @@ -32,8 +32,8 @@ type Retired struct { // bundle is applied again, and the removal pass does what it does for every other resource that // leaves a declaration. // -// That is the whole of why the rename at step 3 mattered. Had the substrate and the module both -// called their container `mesh-control`, this apply would have removed the module's container — +// That is the whole of why the rename at step 3 mattered. Had the foundation and the module both +// called their container `mesh-controller`, this apply would have removed the module's container — // the host would have been asked to take away something it believed it owned, and it would have // been right. Two names, two owners, and the removal is unambiguous. // @@ -63,7 +63,7 @@ func RetireTheTemporaryControlPlane(ctx context.Context, o Options, sys system.S // Textual, for the reason the rewrite at step 3 is textual: the produced bundle is meant to be // READ, and a person coming to a machine after a pivot should be able to open the file the - // installer applied and see the substrate they recognise with the control plane gone from it. + // installer applied and see the foundation they recognise with the control plane gone from it. // Re-serialising a parsed declaration would drop every comment in it. bundle, err := removeResource(produced, ControlPlaneID) if err != nil { @@ -124,7 +124,7 @@ func RetireTheTemporaryControlPlane(ctx context.Context, o Options, sys system.S // removeResource takes one resource out of a bundle's text, comments and all. // // It walks the `resources` array counting braces, skipping over strings and comments so that a -// `//` inside a connection string is not read as the start of one — the substrate's own bundle +// `//` inside a connection string is not read as the start of one — the foundation's own bundle // contains `postgres://…` several times, and a scanner that did not know the difference would // treat the rest of the line as a comment and lose a brace. // diff --git a/internal/bootstrap/retire_test.go b/internal/bootstrap/retire_test.go index efdec44..2a86992 100644 --- a/internal/bootstrap/retire_test.go +++ b/internal/bootstrap/retire_test.go @@ -46,7 +46,7 @@ func TestTheTemporaryControlPlaneLeavesTheBundleAndNothingElseDoes(t *testing.T) t.Error("the bundle still declares a control plane") } // The store and the broker are still exactly what they were. A retirement that took the - // substrate with it would leave the machine with a module and nothing under it. + // foundation with it would leave the machine with a module and nothing under it. for id, name := range containerNames(before) { if id == ControlPlaneID { continue @@ -57,7 +57,7 @@ func TestTheTemporaryControlPlaneLeavesTheBundleAndNothingElseDoes(t *testing.T) } } -// **A `//` inside a string is not a comment.** The substrate's own bundle carries +// **A `//` inside a string is not a comment.** The foundation's own bundle carries // `postgres://…` several times, and a scanner that read the rest of those lines as a comment // would lose braces and cut the wrong thing out — silently, because what it produced would still // look like a file. @@ -144,7 +144,7 @@ func TestAContainerStillThereAfterRemovalIsNotGone(t *testing.T) { stillThere := &asked{answer: func(_ string, _ []string) (string, error) { return "true running\n", nil }} - gone, err := isGone(context.Background(), stillThere.run, time.Second, 0, "temp-mesh-control") + gone, err := isGone(context.Background(), stillThere.run, time.Second, 0, "temp-mesh-controller") if err != nil { t.Fatal(err) } @@ -153,9 +153,9 @@ func TestAContainerStillThereAfterRemovalIsNotGone(t *testing.T) { } removed := &asked{answer: func(_ string, _ []string) (string, error) { - return "", errors.New("No such object: temp-mesh-control") + return "", errors.New("No such object: temp-mesh-controller") }} - gone, err = isGone(context.Background(), removed.run, time.Second, 0, "temp-mesh-control") + gone, err = isGone(context.Background(), removed.run, time.Second, 0, "temp-mesh-controller") if err != nil { t.Fatal(err) } diff --git a/internal/bootstrap/rewrite.go b/internal/bootstrap/rewrite.go index dc040e4..45eb470 100644 --- a/internal/bootstrap/rewrite.go +++ b/internal/bootstrap/rewrite.go @@ -19,27 +19,27 @@ import ( // broker and no mesh. const ControlPlaneID = "control-plane" -// TempPrefix is what the substrate's control plane is renamed with. +// TempPrefix is what the foundation's control plane is renamed with. // -// **This is the whole of how a carried resource becomes a declared one.** The substrate raises a +// **This is the whole of how a carried resource becomes a declared one.** The foundation raises a // control plane and a module later declares one, and for a moment both exist — which looked like a // handover problem needing a way for the host to stop owning something without destroying it. It is -// not one. The temporary control plane is called `temp-mesh-control` and the permanent one is -// called `mesh-control`: two containers, two owners, nothing shared and nothing to hand over. At +// not one. The temporary control plane is called `temp-mesh-controller` and the permanent one is +// called `mesh-controller`: two containers, two owners, nothing shared and nothing to hand over. At // the end the temporary one is dropped from the bundle and the host removes it, which is exactly // what should happen to something named "temp" (novox/hq ADR 0067). // -// The name is also the audit. After the pivot, a machine running `mesh-control` and not -// `temp-mesh-control` has completed it; one running both stopped in the middle; one running only +// The name is also the audit. After the pivot, a machine running `mesh-controller` and not +// `temp-mesh-controller` has completed it; one running both stopped in the middle; one running only // the temp has not started. That is readable from `docker ps` by somebody who knows nothing else. const TempPrefix = "temp-" // ControlPlaneModule is the module the permanent control plane is installed as, and the name its -// container takes — the name the substrate's own control plane gives up here so that it can. +// container takes — the name the foundation's own control plane gives up here so that it can. // // Declared beside the rename rather than beside the step that uses it, because this is where the // two names are decided together and where the reason for both of them is written down. -const ControlPlaneModule = "mesh-control" +const ControlPlaneModule = "mesh-controller" // brokerAddressVar is what a token tells an enrolling node to dial. // @@ -85,11 +85,11 @@ type Rewritten struct { // Rewrite produces the bundle this machine will apply from the template it was given. // // **Two substitutions, and both are textual.** The control plane's image becomes the id of the image -// this machine now holds, and its container is renamed `temp-mesh-control`; nothing else changes. +// this machine now holds, and its container is renamed `temp-mesh-controller`; nothing else changes. // The rename is what makes the pivot expressible at all — see TempPrefix. Textual rather than // parse-and-re-serialise because // the produced file has to be *read* — a person getting a machine working must be able to open it, -// see the substrate they recognise, and see exactly one thing different. Re-serialising a parsed +// see the foundation they recognise, and see exactly one thing different. Re-serialising a parsed // declaration would drop every comment in the template, and those comments are where the reasons // live. // @@ -182,7 +182,7 @@ func Rewrite(template []byte, imageID string) (Rewritten, error) { if produced.Name != out.TempName { return Rewritten{}, fmt.Errorf( "the produced bundle still calls the control plane's container %q, not %q. The "+ - "permanent one is a module and takes the plain name, so a substrate that kept it "+ + "permanent one is a module and takes the plain name, so a foundation that kept it "+ "would put two owners on one container", produced.Name, out.TempName) } @@ -209,7 +209,7 @@ func Rewrite(template []byte, imageID string) (Rewritten, error) { return Rewritten{}, fmt.Errorf( "renaming the control plane's container also renamed %q, from %q to %q. Only the "+ "control plane moves out of the way; every other container keeps the name the "+ - "substrate gave it", id, wasName[id], name) + "foundation gave it", id, wasName[id], name) } sortStrings(out.Kept) return out, nil @@ -217,15 +217,15 @@ func Rewrite(template []byte, imageID string) (Rewritten, error) { // renameContainer changes one container's name in the bundle's text. // -// **The quoted name, not the bare word.** `mesh-control` also appears inside the image reference -// the template carries (`…/mesh-control@sha256:…`) and could appear inside a command line; a bare +// **The quoted name, not the bare word.** `mesh-controller` also appears inside the image reference +// the template carries (`…/mesh-controller@sha256:…`) and could appear inside a command line; a bare // substitution would catch those too. What is wanted is a JSON string that IS the name, so the -// quotes are part of what is matched — `"mesh-control"` matches the container's `name` and an +// quotes are part of what is matched — `"mesh-controller"` matches the container's `name` and an // action's `in`, which are exactly the places the name means the container, and nothing else. // // It refuses when the text does not contain what the parse says is there, for the same reason the // image substitution does: the two would then be reading different things, and a rename that -// replaced nothing and reported success would leave the module and the substrate fighting over one +// replaced nothing and reported success would leave the module and the foundation fighting over one // container three steps later. func renameContainer(bundle []byte, from, to string) ([]byte, error) { if from == to { @@ -235,7 +235,7 @@ func renameContainer(bundle []byte, from, to string) ([]byte, error) { if bytes.Count(bundle, quoted) == 0 { return nil, fmt.Errorf( "the control plane's container is called %q according to the parsed template, and %s "+ - "is not in the file. Nothing was renamed, and the substrate would raise a "+ + "is not in the file. Nothing was renamed, and the foundation would raise a "+ "container the module also wants", from, quoted) } return bytes.ReplaceAll(bundle, quoted, []byte(`"`+to+`"`)), nil @@ -257,7 +257,7 @@ func controlPlaneIn(d *declaration.Declaration) (*declaration.Container, error) } return nil, fmt.Errorf( "this bundle names no %q, so there is no control plane to give this machine's image to. "+ - "A substrate without one raises a store and a broker and no mesh. It declares: %s", + "A foundation without one raises a store and a broker and no mesh. It declares: %s", ControlPlaneID, strings.Join(identities(d), ", ")) } @@ -316,7 +316,7 @@ func sortStrings(values []string) { // writeBundleFile puts the produced bundle where a person can read it, creating the directory it // lives in. // -// 0644, and that is deliberate: this file names an image and describes a substrate, and it holds +// 0644, and that is deliberate: this file names an image and describes a foundation, and it holds // the bootstrap credentials the template happens to carry — which are the same ones anybody can // read in the template itself. It is meant to be read. What must not be world-readable is the // node's identity, and that lives elsewhere and is written elsewhere (`internal/identity`). diff --git a/internal/bootstrap/rewrite_test.go b/internal/bootstrap/rewrite_test.go index 7902f1c..0ad169c 100644 --- a/internal/bootstrap/rewrite_test.go +++ b/internal/bootstrap/rewrite_test.go @@ -15,16 +15,16 @@ const ( otherHeld = "sha256:2222222222222222222222222222222222222222222222222222222222222222" ) -// theRealBundle is this repository's own substrate example, used rather than a fixture. +// theRealBundle is this repository's own foundation example, used rather than a fixture. // // A fixture would agree with whatever this code does. The example is what an installer is actually -// pointed at, it names the control plane twice, and it is the file that changes when the substrate +// pointed at, it names the control plane twice, and it is the file that changes when the foundation // changes — so a rewrite that stops working on it is a rewrite that has stopped working. func theRealBundle(t *testing.T) []byte { t.Helper() - raw, err := os.ReadFile("../../examples/substrate-first-node.lock") + raw, err := os.ReadFile("../../examples/foundation-first-node.lock") if err != nil { - t.Fatalf("reading the substrate example: %v", err) + t.Fatalf("reading the foundation example: %v", err) } return raw } @@ -48,7 +48,7 @@ func TestTheControlPlaneIsNamedByTheImageThisMachineHolds(t *testing.T) { // **Every place the bundle names that image, not only the container.** // -// The substrate names the control plane's image twice: the container that runs `serve`, and the +// The foundation names the control plane's image twice: the container that runs `serve`, and the // action that runs `migrate` to create the contexts' schemas. Rewriting only the container leaves // the migration pointing at an image no registry serves, and the apply dies in the middle — after // the store is up and before the broker. This is the test that would have caught that. @@ -60,7 +60,7 @@ func TestEveryPlaceTheBundleNamesTheControlPlaneIsRewritten(t *testing.T) { t.Fatal(err) } if out.Places < 2 { - t.Fatalf("the control plane's image was found in %d place(s); the substrate names it in "+ + t.Fatalf("the control plane's image was found in %d place(s); the foundation names it in "+ "the container AND in the migration action", out.Places) } if remaining := strings.Count(string(out.Bundle), out.Was); remaining != 0 { @@ -86,7 +86,7 @@ func TestPostgresAndTheBrokerAreLeftExactlyAsTheyWere(t *testing.T) { for _, id := range []string{"store", "broker"} { image, named := produced[id] if !named { - t.Fatalf("the substrate example no longer declares a %q container", id) + t.Fatalf("the foundation example no longer declares a %q container", id) } // Compared against the template's own text rather than against an expectation written // here: what is being defended is "unchanged", and the template is the only thing that @@ -126,7 +126,7 @@ func TestABundleThatNamesNoControlPlaneIsRefused(t *testing.T) { func TestAControlPlaneThatIsNotAContainerIsRefused(t *testing.T) { template := []byte(`{"declaration":1,"resources":[ - {"id":"control-plane","type":"package","package":"mesh-control"} + {"id":"control-plane","type":"package","package":"mesh-controller"} ]}`) if _, err := Rewrite(template, held); err == nil { t.Fatal("a control plane declared as a package was accepted, and a package has no image") @@ -175,7 +175,7 @@ func TestANewImageReplacesAnOlderHeldOne(t *testing.T) { } // The produced bundle is meant to be READ. Re-serialising a parsed declaration would drop every -// comment in the template, and the substrate example is mostly comments — each one recording why a +// comment in the template, and the foundation example is mostly comments — each one recording why a // resource is the way it is, several of them paid for in the lab. func TestTheProducedBundleKeepsTheTemplatesComments(t *testing.T) { template := theRealBundle(t) @@ -194,11 +194,11 @@ func TestTheProducedBundleKeepsTheTemplatesComments(t *testing.T) { func TestSomethingThatIsNotAnImageIdIsRefused(t *testing.T) { for _, bad := range []string{ "", - "mesh-control:latest", + "mesh-controller:latest", "sha256:abc", "sha256:" + strings.Repeat("1", 63), "sha256:" + strings.Repeat("g", 64), - "mesh-control@sha256:" + strings.Repeat("1", 64), + "mesh-controller@sha256:" + strings.Repeat("1", 64), } { if _, err := Rewrite(theRealBundle(t), bad); err == nil { t.Errorf("image id %q was accepted", bad) @@ -216,7 +216,7 @@ func TestTheAddressNodesWillDialIsReportedAndNotRewritten(t *testing.T) { t.Fatal(err) } if out.BrokerAddress == "" { - t.Fatal("the substrate example no longer says what address enrolling nodes will dial") + t.Fatal("the foundation example no longer says what address enrolling nodes will dial") } if !strings.Contains(string(out.Bundle), out.BrokerAddress) { t.Errorf("the produced bundle no longer carries %q — it was rewritten, and nothing here "+ @@ -228,21 +228,21 @@ func TestTheAddressNodesWillDialIsReportedAndNotRewritten(t *testing.T) { // The rename, which is what makes genesis a pivot rather than a handover (novox/hq ADR 0067). // --------------------------------------------------------------------------------------------- -// **This is the test that dissolves the blocker.** The substrate raises a control plane and a -// module later declares one; if both are called `mesh-control` then for one moment two owners hold +// **This is the test that dissolves the blocker.** The foundation raises a control plane and a +// module later declares one; if both are called `mesh-controller` then for one moment two owners hold // one container, and the host — which tracks what it owns — has no way to stop owning something -// without destroying it. Nothing here invents such a mechanism. The substrate's container is -// called `temp-mesh-control` instead, and there are simply two containers. -func TestTheSubstratesControlPlaneMovesOutOfTheModulesWay(t *testing.T) { +// without destroying it. Nothing here invents such a mechanism. The foundation's container is +// called `temp-mesh-controller` instead, and there are simply two containers. +func TestTheFoundationsControlPlaneMovesOutOfTheModulesWay(t *testing.T) { out, err := Rewrite(theRealBundle(t), held) if err != nil { t.Fatal(err) } if !out.Renamed { - t.Error("the rewrite reported nothing renamed, and the template named it mesh-control") + t.Error("the rewrite reported nothing renamed, and the template named it mesh-controller") } - if out.TempName != "temp-mesh-control" { - t.Errorf("the substrate's control plane is called %q", out.TempName) + if out.TempName != "temp-mesh-controller" { + t.Errorf("the foundation's control plane is called %q", out.TempName) } control, err := controlPlaneIn(out.Declaration) if err != nil { @@ -260,22 +260,22 @@ func TestTheSubstratesControlPlaneMovesOutOfTheModulesWay(t *testing.T) { } } -// The image reference contains the string `mesh-control` too, and it is not a container name. A -// substitution that caught it would produce `…/temp-mesh-control@sha256:…`, which no registry +// The image reference contains the string `mesh-controller` too, and it is not a container name. A +// substitution that caught it would produce `…/temp-mesh-controller@sha256:…`, which no registry // serves — and it would be found inside a pull rather than here. func TestTheImageReferenceIsNotMistakenForTheContainerName(t *testing.T) { out, err := Rewrite(theRealBundle(t), held) if err != nil { t.Fatal(err) } - if strings.Contains(string(out.Bundle), TempPrefix+"mesh-control@") || - strings.Contains(string(out.Bundle), "/"+TempPrefix+"mesh-control") { + if strings.Contains(string(out.Bundle), TempPrefix+"mesh-controller@") || + strings.Contains(string(out.Bundle), "/"+TempPrefix+"mesh-controller") { t.Error("the rename reached inside an image reference") } } -// Everything else keeps the name the substrate gave it. The store and the broker are containers -// too, and a rename that moved them would leave a machine whose substrate the host cannot find. +// Everything else keeps the name the foundation gave it. The store and the broker are containers +// too, and a rename that moved them would leave a machine whose foundation the host cannot find. func TestRenamingTheControlPlaneLeavesEveryOtherContainerAlone(t *testing.T) { before, err := declaration.ParseFileTrusted(theRealBundle(t)) if err != nil { @@ -309,7 +309,7 @@ func TestRewritingABundleThisAlreadyProducedRenamesNothing(t *testing.T) { t.Fatal(err) } if second.Renamed { - t.Error("a bundle already naming temp-mesh-control was renamed again") + t.Error("a bundle already naming temp-mesh-controller was renamed again") } if second.TempName != first.TempName { t.Errorf("the second pass calls it %q and the first called it %q", diff --git a/internal/bootstrap/talk.go b/internal/bootstrap/talk.go index eab5a14..3b5b029 100644 --- a/internal/bootstrap/talk.go +++ b/internal/bootstrap/talk.go @@ -13,13 +13,13 @@ import ( // // **Through `docker exec`, not over a network.** The control plane listens on nothing — `serve` is // a broker consumer, and every administrative verb is a subcommand of the same binary that opens -// the stores directly (mesh-control's own usage). So the way to tell a mesh anything, from the +// the stores directly (mesh-controller's own usage). So the way to tell a mesh anything, from the // machine the mesh is on, is to run its binary inside its own container. That is also what the lab // does, and having the installer and the lab drive the mesh identically is the point: the lab is // meant to exercise the installer, not a second procedure that resembles it. // // It carries which container, because the whole pivot turns on there being two of them: the -// substrate's `temp-mesh-control` for steps 6 to 9, and the module's `mesh-control` afterwards. +// foundation's `temp-mesh-controller` for steps 6 to 9, and the module's `mesh-controller` afterwards. type controlPlane struct { container string run Runner @@ -35,9 +35,9 @@ func (c controlPlane) within(timeout time.Duration) controlPlane { return c } -// tell runs a mesh-control subcommand and gives back what it said. +// tell runs a mesh-controller subcommand and gives back what it said. // -// The failure carries the command AND the output. A mesh-control refusal is a paragraph explaining +// The failure carries the command AND the output. A mesh-controller refusal is a paragraph explaining // what is wrong — "nothing provides route, wanted by registry" — and an installer that reported // only "exit status 1" would throw away the one thing a person needs. func (c controlPlane) tell(ctx context.Context, args ...string) (string, error) { @@ -83,7 +83,7 @@ func (c controlPlane) carry(ctx context.Context, local, remote string) error { // crash-looped on material it never received. There is no shell in the image to chown it with. // // What goes through here is a module manifest and a store connection string. The connection is the -// same value the produced bundle already holds in the clear — a substrate names its own bootstrap +// same value the produced bundle already holds in the clear — a foundation names its own bootstrap // credentials, and at genesis there is nowhere else for them to be — so this widens nothing. The // file on the machine is removed at once, and the copy inside the container goes when the // container does, which for the temporary control plane is step 10. @@ -107,7 +107,7 @@ func indent(s string) string { // // Line-and-word rather than a substring search, because these listings are columns and a // substring match would find `registry` inside `registry-mirror` and report a module installed -// that is not. Every one of mesh-control's `list` verbs prints the name first on the line. +// that is not. Every one of mesh-controller's `list` verbs prints the name first on the line. func mentions(listing, name string) bool { for _, line := range strings.Split(listing, "\n") { first, _, _ := strings.Cut(strings.TrimSpace(line), " ") diff --git a/internal/bootstrap/verify.go b/internal/bootstrap/verify.go index 4bed6fe..6285257 100644 --- a/internal/bootstrap/verify.go +++ b/internal/bootstrap/verify.go @@ -13,14 +13,14 @@ import ( // // A path rather than a shell command, because the image is `FROM scratch` and holds one static // binary and nothing else — no shell to invoke, nothing to interpret a command line -// (mesh-control's Dockerfile, novox/hq ADR 0006). That is a property of the image this installer +// (mesh-controller's Dockerfile, novox/hq ADR 0006). That is a property of the image this installer // carries, which is why the path can be written down here. -const controlPlaneBinary = "/mesh-control" +const controlPlaneBinary = "/mesh-controller" // answerEvery is how often the control plane is asked again while it is starting. var answerEvery = 2 * time.Second -// Verified is what the substrate was found to be. +// Verified is what the foundation was found to be. type Verified struct { // Running is every long-running container the bundle declares, confirmed up. Running []string @@ -29,7 +29,7 @@ type Verified struct { Answered string } -// Verify proves the substrate is up and the control plane replies. +// Verify proves the foundation is up and the control plane replies. // // **A container that is up is not a control plane that replies**, and this project has paid for // that distinction more than once: a runtime reports a container running from the moment the @@ -146,7 +146,7 @@ func containerRunning(ctx context.Context, run Runner, probe time.Duration, name // // A run-once step has exited by design and a scheduled step has deliberately never been started // (novox/hq ADR 0052, ADR 0053), so asking either of them to be running would be asking the -// substrate to be something other than what it declared. +// foundation to be something other than what it declared. func longRunning(d *declaration.Declaration) []string { var names []string for _, r := range d.Resources { diff --git a/internal/bootstrap/verify_test.go b/internal/bootstrap/verify_test.go index 3711d7d..c8decc6 100644 --- a/internal/bootstrap/verify_test.go +++ b/internal/bootstrap/verify_test.go @@ -11,7 +11,7 @@ import ( "github.com/novox/mesh-host/internal/declaration" ) -func substrate(t *testing.T) *declaration.Declaration { +func foundation(t *testing.T) *declaration.Declaration { t.Helper() out, err := Rewrite(theRealBundle(t), held) if err != nil { @@ -39,12 +39,12 @@ func TestAContainerThatIsUpIsNotAControlPlaneThatReplies(t *testing.T) { return "", fmt.Errorf("unexpected command: %v", args) }} - _, err := Verify(context.Background(), substrate(t), runtime.run, + _, err := Verify(context.Background(), foundation(t), runtime.run, time.Second, 0, func(string) {}) if err == nil { - t.Fatal("every container was running, nothing answered, and the substrate was reported up") + t.Fatal("every container was running, nothing answered, and the foundation was reported up") } - for _, wanted := range []string{"mesh-control", "Running is not replying", "docker logs"} { + for _, wanted := range []string{"mesh-controller", "Running is not replying", "docker logs"} { if !strings.Contains(err.Error(), wanted) { t.Errorf("the failure does not mention %q:\n%v", wanted, err) } @@ -65,14 +65,14 @@ func TestAControlPlaneThatSaysNothingHasNotAnswered(t *testing.T) { return " \n", nil }} - if _, err := Verify(context.Background(), substrate(t), runtime.run, + if _, err := Verify(context.Background(), foundation(t), runtime.run, time.Second, 0, func(string) {}); err == nil { t.Fatal("a control plane that exited zero without saying anything was accepted") } } -// The substrate answering is the whole point, and what it said is reported rather than asserted. -func TestASubstrateThatIsUpAndAnsweringIsAccepted(t *testing.T) { +// The foundation answering is the whole point, and what it said is reported rather than asserted. +func TestAFoundationThatIsUpAndAnsweringIsAccepted(t *testing.T) { runtime := &asked{answer: func(_ string, args []string) (string, error) { if args[0] == "inspect" { return "true running\n", nil @@ -80,16 +80,16 @@ func TestASubstrateThatIsUpAndAnsweringIsAccepted(t *testing.T) { return "1 node, 0 waiting\n", nil }} - verified, err := Verify(context.Background(), substrate(t), runtime.run, + verified, err := Verify(context.Background(), foundation(t), runtime.run, time.Second, 0, func(string) {}) if err != nil { t.Fatal(err) } // Three long-running containers: the store, the broker and the TEMPORARY control plane. The // run-once and scheduled shapes are excluded on purpose — a step that has exited is not a - // fault. The name is `temp-mesh-control` because the permanent one is a module and takes the + // fault. The name is `temp-mesh-controller` because the permanent one is a module and takes the // plain name (novox/hq ADR 0067), which is what makes the two of them coexist at all. - want := []string{"mesh-store", "mesh-broker", "temp-mesh-control"} + want := []string{"mesh-store", "mesh-broker", "temp-mesh-controller"} if len(verified.Running) != len(want) { t.Fatalf("confirmed %v running, want %v", verified.Running, want) } @@ -122,7 +122,7 @@ func TestAControlPlaneThatIsStillStartingIsWaitedFor(t *testing.T) { return "1 node\n", nil }} - if _, err := Verify(context.Background(), substrate(t), runtime.run, + if _, err := Verify(context.Background(), foundation(t), runtime.run, time.Second, time.Second, func(string) {}); err != nil { t.Fatalf("a control plane that answered on the third ask was refused: %v", err) } @@ -141,10 +141,10 @@ func TestAContainerThatExitedIsNamedWithItsState(t *testing.T) { return "", fmt.Errorf("unexpected command: %v", args) }} - _, err := Verify(context.Background(), substrate(t), runtime.run, + _, err := Verify(context.Background(), foundation(t), runtime.run, time.Second, 0, func(string) {}) if err == nil { - t.Fatal("a container that had exited was reported as part of a running substrate") + t.Fatal("a container that had exited was reported as part of a running foundation") } if !strings.Contains(err.Error(), "mesh-broker") || !strings.Contains(err.Error(), "exited") { t.Errorf("the failure does not say which container is in what state: %v", err) @@ -160,11 +160,11 @@ func TestTheControlPlaneIsAskedByRunningItsOwnBinary(t *testing.T) { } return "1 node\n", nil }} - if _, err := Verify(context.Background(), substrate(t), runtime.run, + if _, err := Verify(context.Background(), foundation(t), runtime.run, time.Second, 0, func(string) {}); err != nil { t.Fatal(err) } - if !runtime.ran("docker exec " + TempPrefix + "mesh-control " + controlPlaneBinary + " status") { + if !runtime.ran("docker exec " + TempPrefix + "mesh-controller " + controlPlaneBinary + " status") { t.Errorf("the control plane was never asked anything: %v", runtime.commands) } } diff --git a/internal/bundle/bundle.go b/internal/bundle/bundle.go index f9f3c6b..329f10f 100644 --- a/internal/bundle/bundle.go +++ b/internal/bundle/bundle.go @@ -27,13 +27,13 @@ import ( // nothing and reporting success — a host that silently did nothing on a first node would look // exactly like one that worked. // -//go:embed substrate-arch.lock +//go:embed foundation-arch.lock var archLock []byte -//go:embed substrate-alpine.lock +//go:embed foundation-alpine.lock var alpineLock []byte -//go:embed substrate-android.lock +//go:embed foundation-android.lock var androidLock []byte var locks = map[string][]byte{ @@ -52,7 +52,7 @@ func Raw(system string) []byte { return locks[system] } // IsEmpty reports whether anything was built in. A bundle of only comments and whitespace is // empty for this purpose: a placeholder is a comment, and treating it as content would mean a -// host claims to carry a substrate it does not. +// host claims to carry a foundation it does not. func IsEmpty(system string) bool { for _, line := range strings.Split(string(locks[system]), "\n") { line = strings.TrimSpace(line) diff --git a/internal/bundle/bundle_test.go b/internal/bundle/bundle_test.go index b7fb1ea..232971d 100644 --- a/internal/bundle/bundle_test.go +++ b/internal/bundle/bundle_test.go @@ -13,7 +13,7 @@ func TestADefaultBuildCarriesNothingAndSaysSo(t *testing.T) { // success would look exactly like a host that raised a first node — and the difference // would surface as a mesh that never came up, with nothing to point at. if !IsEmpty("arch") { - t.Fatal("the default build claims to carry a substrate") + t.Fatal("the default build claims to carry a foundation") } _, err := Load("arch") if !errors.Is(err, ErrEmpty) { diff --git a/internal/bundle/substrate-alpine.lock b/internal/bundle/foundation-alpine.lock similarity index 87% rename from internal/bundle/substrate-alpine.lock rename to internal/bundle/foundation-alpine.lock index 1d0f5cc..4630a96 100644 --- a/internal/bundle/substrate-alpine.lock +++ b/internal/bundle/foundation-alpine.lock @@ -1,4 +1,4 @@ -// substrate-alpine.lock — the pinned tier-1 descriptor the ALPINE host carries. +// foundation-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 0005). diff --git a/internal/bundle/substrate-android.lock b/internal/bundle/foundation-android.lock similarity index 66% rename from internal/bundle/substrate-android.lock rename to internal/bundle/foundation-android.lock index 6ee155f..20c3711 100644 --- a/internal/bundle/substrate-android.lock +++ b/internal/bundle/foundation-android.lock @@ -1,10 +1,10 @@ -// substrate-android.lock — deliberately not a bundle. +// foundation-android.lock — deliberately not a bundle. // // An android host cannot raise a mesh, and this file says so rather than being an empty // placeholder waiting to be filled in. // -// 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 +// The foundation is a container runtime, a store and the control plane (novox/hq +// 07-the-foundation.md). An android host implements `file`, `directory` and `action` and refuses // `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. // diff --git a/internal/bundle/substrate-arch.lock b/internal/bundle/foundation-arch.lock similarity index 88% rename from internal/bundle/substrate-arch.lock rename to internal/bundle/foundation-arch.lock index 21cc7be..762792e 100644 --- a/internal/bundle/substrate-arch.lock +++ b/internal/bundle/foundation-arch.lock @@ -1,4 +1,4 @@ -// substrate-arch.lock — the pinned tier-1 descriptor the ARCH host carries. +// foundation-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 0005). The mechanism is shared; what it names is not. diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index 909e540..d1ffc9c 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -1076,7 +1076,7 @@ func vocabulary() string { // ParseFileTrusted reads a declaration from a file somebody handed this host. // // The same as ParseTrusted, and it allows whole-line `//` comments first. A pinned, hand-authored -// artefact that nobody can annotate is one nobody can review — the substrate bundle is mostly +// artefact that nobody can annotate is one nobody can review — the foundation bundle is mostly // explanation of why each digest is what it is. // // **Only for a file, never for the link.** Over the link the format stays exactly JSON, because diff --git a/internal/declaration/declaration_test.go b/internal/declaration/declaration_test.go index 9a0c27a..b2fd73e 100644 --- a/internal/declaration/declaration_test.go +++ b/internal/declaration/declaration_test.go @@ -157,7 +157,7 @@ func TestAnEmptyDeclarationIsAMistake(t *testing.T) { refusalFor(t, `{"declaration":1,"resources":[]}`) } -// --- the vocabulary the substrate bootstrap needs (novox/hq 07-the-substrate.md) --- +// --- the vocabulary the foundation bootstrap needs (novox/hq 07-the-foundation.md) --- func TestAnActionOverTheLinkIsRefused(t *testing.T) { // novox/hq ADR 0005. The link may push declarations of known shape and never a command to @@ -229,7 +229,7 @@ func TestAnImageMustBePinnedByDigest(t *testing.T) { func TestAnImageTheMachineHoldsIsNamedByItsOwnDigest(t *testing.T) { held := "sha256:" + strings.Repeat("b", 64) if _, err := ParseTrusted([]byte(`{"declaration":1,"resources":[ - {"id":"control","type":"container","name":"mesh-control","image":"` + held + `"} + {"id":"control","type":"container","name":"mesh-controller","image":"` + held + `"} ]}`)); err != nil { t.Errorf("an image named by its own digest was refused: %v", err) } @@ -238,7 +238,7 @@ func TestAnImageTheMachineHoldsIsNamedByItsOwnDigest(t *testing.T) { // whichever the runtime happened to match first. for _, bad := range []string{"sha256:abc", "sha256:", "sha256:" + strings.Repeat("b", 63)} { if _, err := ParseTrusted([]byte(`{"declaration":1,"resources":[ - {"id":"control","type":"container","name":"mesh-control","image":"` + bad + `"} + {"id":"control","type":"container","name":"mesh-controller","image":"` + bad + `"} ]}`)); err == nil { t.Errorf("image id %q was accepted and is not a digest", bad) } @@ -267,7 +267,7 @@ func TestAFieldTheNewTypesDoNotUseIsRefused(t *testing.T) { } func TestTheVocabularyIsTheElevenShapesTheMeshNeeds(t *testing.T) { - // Six of them the bootstrap uses (novox/hq 07-the-substrate.md), and removing one is a + // Six of them the bootstrap uses (novox/hq 07-the-foundation.md), and removing one is a // failing test rather than a discovery during a first-node install. // // Two were added on 2026-08-30 and the count is asserted precisely because adding one is a @@ -364,7 +364,7 @@ func TestSomethingInsideAValueIsNotAComment(t *testing.T) { // parses as the declaration and the rest is never looked at. The machine applies something, // reports success, and what it applied is not what the file says. // -// Not hypothetical: a test harness appended a line to the substrate bundle by accident, every +// Not hypothetical: a test harness appended a line to the foundation bundle by accident, every // apply kept working, and nothing said so for the entire time it was wrong. func TestSomethingAfterTheDeclarationIsRefused(t *testing.T) { good := `{"declaration":1,"resources":[{"id":"a","type":"file","path":"/tmp/a",` + diff --git a/internal/image/builder.tar b/internal/image/builder.tar index e26e4f9..d1d3e70 100644 --- a/internal/image/builder.tar +++ b/internal/image/builder.tar @@ -2,7 +2,7 @@ This is not a saved image. It is the placeholder that keeps this repository buil A release build replaces this file with the output of `docker save` and puts it back afterwards: - make bootstrap IMAGE=mesh-control: + make bootstrap IMAGE=mesh-controller: An installer built with this file present carries no control plane, and says so in preflight rather than getting a machine part-way to being a mesh and stopping. diff --git a/internal/image/image.go b/internal/image/image.go index 70d2386..ef068be 100644 --- a/internal/image/image.go +++ b/internal/image/image.go @@ -58,7 +58,7 @@ var saved []byte var ErrEmpty = errors.New( "this mesh-bootstrap carries no builder image, so it cannot raise a mesh. A release build " + "embeds one: `make bootstrap IMAGE=` in the mesh-host repository, where " + - "is a mesh-builder image already built from the mesh-control source") + "is a mesh-builder image already built from the mesh-controller source") // IsEmpty reports whether anything was built in. // diff --git a/internal/image/image_test.go b/internal/image/image_test.go index ef93e6a..ef4d0dd 100644 --- a/internal/image/image_test.go +++ b/internal/image/image_test.go @@ -72,11 +72,11 @@ func TestTheArchivesOwnIdIsReadFromTheSavedFile(t *testing.T) { // does not. func TestTheSavedTagsAreRead(t *testing.T) { saved := savedImage(t, map[string]string{ - "manifest.json": manifest(t, strings.Repeat("b", 64)+".json", "mesh-control:v1"), + "manifest.json": manifest(t, strings.Repeat("b", 64)+".json", "mesh-controller:v1"), }) got := Tags(saved) - if len(got) != 1 || got[0] != "mesh-control:v1" { - t.Errorf("tags = %v, want [mesh-control:v1]", got) + if len(got) != 1 || got[0] != "mesh-controller:v1" { + t.Errorf("tags = %v, want [mesh-controller:v1]", got) } } diff --git a/internal/link/messages.go b/internal/link/messages.go index becc2ef..67b63c5 100644 --- a/internal/link/messages.go +++ b/internal/link/messages.go @@ -60,7 +60,7 @@ type Report struct { // Carried are the machine's ports held by what this host raised from its own bundle. // // **So the mesh can assign around what it did not put here** (novox/hq ADR 0038). A node - // raises its substrate before any mesh exists, so the control plane has never heard of the + // raises its foundation before any mesh exists, so the control plane has never heard of the // store or the broker — and would hand a module a port one of them holds, discovering it only // when a container runtime refused to start. // diff --git a/internal/store/store.go b/internal/store/store.go index e0669d7..d9c097f 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -35,7 +35,7 @@ type Applied struct { Type string `json:"type"` // Origin is who asked for this: the bundle this host carries, or the mesh. // - // Recorded because the two must not remove each other. A node raises its own substrate from + // Recorded because the two must not remove each other. A node raises its own foundation from // the bundle before any mesh exists, then enrols and is sent declarations — and a // declaration naming two resources would otherwise remove the store, the broker and the // control plane, which is 04-ISSUES/010 and happened on the first end-to-end run. @@ -47,7 +47,7 @@ type Applied struct { // Holds are the machine's own ports this resource occupies. // // **So the mesh can assign around what it did not put here** (novox/hq ADR 0038). A node - // raises its substrate from the bundle before any mesh exists, so the control plane has never + // raises its foundation from the bundle before any mesh exists, so the control plane has never // heard of the store, the broker or the control plane's own container — and a module assigned // afterwards would be given a port one of them already holds, and would be told so by a // container runtime rather than by anything that could have prevented it. @@ -226,7 +226,7 @@ const ( // // State written before origins existed was all bundle-applied: a host had no other way to be // told anything. Guessing wrong in the other direction would have a first upgrade remove the -// substrate, which is the fault this field exists to prevent. +// foundation, which is the fault this field exists to prevent. func originOf(r Applied) string { if r.Origin == "" { return OriginCarried diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 3867be9..712c3bd 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -136,7 +136,7 @@ func TestNothingIsAnOrphanWhenEverythingIsDeclared(t *testing.T) { } func TestADeclarationDoesNotOrphanWhatTheBundleRaised(t *testing.T) { - // 04-ISSUES/010. A first node raises its substrate from the bundle it carries, then enrols + // 04-ISSUES/010. A first node raises its foundation from the bundle it carries, then enrols // and is sent a declaration naming two resources. Before origins, that removed the store, the // broker and the control plane that had sent it — the mesh deleting itself over the link the // message arrived on, in under a second, on the first end-to-end run. @@ -175,7 +175,7 @@ func TestTheBundleDoesNotOrphanWhatTheMeshDeclared(t *testing.T) { func TestStateWrittenBeforeOriginsExistedIsTreatedAsCarried(t *testing.T) { // Every resource a host had applied before this field existed came from its bundle, because // there was no other way to tell it anything. Guessing the other way would have the first - // declaration remove the substrate — which is the fault this exists to prevent, arriving + // declaration remove the foundation — which is the fault this exists to prevent, arriving // through the upgrade that fixes it. s := State{Resources: []Applied{{ID: "store", Type: "container", Target: "mesh-store"}}} diff --git a/internal/system/android.go b/internal/system/android.go index b74e10b..f46396b 100644 --- a/internal/system/android.go +++ b/internal/system/android.go @@ -35,7 +35,7 @@ import ( // while disconnected, reconcile already happens on start, and the mesh already reports *last // heard from* rather than alarming on silence. // -// It also **cannot be the first node** — every step of raising a substrate is a shape it +// It also **cannot be the first node** — every step of raising a foundation is a shape it // refuses — and its bundle says so rather than being an empty placeholder. type android struct{} From 9109a8c17809bd05722d15ecd18ef4d6b087bc20 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 16 Sep 2026 20:32:12 +0200 Subject: [PATCH 10/12] Phase 3.1: adopt the foundation store as the postgres module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InstallStore turns the mesh-store the foundation raised at genesis into the postgres module, adopted in place: it verifies the module's server names the same container and the same image the foundation is running (fail-fast on a drift, rather than tearing down the mesh's store), then registers, builds the provisioner, and carries the superuser in via secret accept — the mesh cannot invent a credential that already made the databases (mirroring the control plane's store-connection delivery, control.go). pinImage generalised to any module for reuse. Issue 051 (WBS 3.1). One server holds the controller's contexts and every module's database. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- examples/foundation-first-node.lock | 2 +- internal/bootstrap/bootstrap.go | 10 +- internal/bootstrap/builder.go | 2 +- internal/bootstrap/control.go | 14 +- internal/bootstrap/control_test.go | 6 +- internal/bootstrap/phase3.go | 205 ++++++++++++++++++++++++++++ 6 files changed, 223 insertions(+), 16 deletions(-) create mode 100644 internal/bootstrap/phase3.go diff --git a/examples/foundation-first-node.lock b/examples/foundation-first-node.lock index 3f516b1..d5bfdd1 100644 --- a/examples/foundation-first-node.lock +++ b/examples/foundation-first-node.lock @@ -55,7 +55,7 @@ "POSTGRES_PASSWORD": "bootstrap", "PGDATA": "/var/lib/postgresql/data/pgdata" }, - "ports": ["127.0.0.1:5432:5432"], + "ports": ["5432:5432"], "volumes": ["mesh-store-data:/var/lib/postgresql/data"] }, // Over TCP, not the socket. While the store initialises it runs a temporary server on the diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index e65496b..3b7013f 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -588,10 +588,12 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro } // ---- 14. store ------------------------------------------------------------------------ - // A database PROVIDER. The foundation'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 foundation's own store is not") - if err := InstallFromCatalogue(ctx, o, permanentControl, "postgres", say); err != nil { + // The foundation's own store, ADOPTED as the `postgres` module (novox/hq issue 051): one + // server holds the control plane's contexts and every module's database, rather than the + // foundation's store beside a second one a module raised. Adopted in place — the module names + // the container the foundation is already running, and the applier leaves it be (phase3.go). + say("store — the foundation's store, adopted as the postgres module: one server, not two") + if err := InstallStore(ctx, o, permanentControl, rewritten.Declaration, say); err != nil { return result, failed(StepStore, err) } diff --git a/internal/bootstrap/builder.go b/internal/bootstrap/builder.go index 865bc83..1b3d94f 100644 --- a/internal/bootstrap/builder.go +++ b/internal/bootstrap/builder.go @@ -53,7 +53,7 @@ func InstallBuilder(ctx context.Context, o Options, d Deps, control controlPlane "This is the manifest that makes the builder an ordinary module. Without it the mesh "+ "has the image and no way to run it, so nothing can be built here", err) } - pinned, places, err := pinImage(manifest, published.Reference) + pinned, places, err := pinImage(manifest, published.Reference, BuilderModule) if err != nil { return out, err } diff --git a/internal/bootstrap/control.go b/internal/bootstrap/control.go index e7a655a..3125dd0 100644 --- a/internal/bootstrap/control.go +++ b/internal/bootstrap/control.go @@ -68,7 +68,7 @@ func InstallControlPlane(ctx context.Context, o Options, d Deps, control control "have pivoted", err) } - pinned, places, err := pinImage(manifest, image) + pinned, places, err := pinImage(manifest, image, ControlPlaneModule) if err != nil { return out, err } @@ -130,15 +130,15 @@ func InstallControlPlane(ctx context.Context, o Options, d Deps, control control // carrying a real digest is one somebody pinned by hand, and quietly registering it would install a // control plane that is not the image this machine just published — which is the one thing this // step exists to guarantee. -func pinImage(manifest []byte, reference string) ([]byte, int, error) { +func pinImage(manifest []byte, reference, module string) ([]byte, int, error) { places := bytes.Count(manifest, []byte(placeholderDigest)) if places == 0 { return nil, 0, fmt.Errorf( "the %s module's manifest carries no placeholder digest (%s), so there is nothing to "+ "pin to the image this machine just published.\n"+ "A manifest already naming a digest was pinned by somebody else, to some other "+ - "build. Registering it would install a control plane that is not the one this "+ - "installer carried and pushed", ControlPlaneModule, placeholderDigest) + "build. Registering it would install a module that is not the one this "+ + "installer carried and pushed", module, placeholderDigest) } // The reference the registry gave back is `/@sha256:…`, and what the // manifest holds is `@sha256:0…0`. Replacing only the digest would leave the @@ -157,7 +157,7 @@ func pinImage(manifest []byte, reference string) ([]byte, int, error) { if start < 0 { return nil, 0, fmt.Errorf( "the %s module's manifest has a placeholder digest that is not inside a JSON "+ - "string, so the installer cannot tell what image it belongs to", ControlPlaneModule) + "string, so the installer cannot tell what image it belongs to", module) } out.Write(rest[:start+1]) out.WriteString(reference) @@ -170,12 +170,12 @@ func pinImage(manifest []byte, reference string) ([]byte, int, error) { var checked map[string]any if err := json.Unmarshal(pinned, &checked); err != nil { return nil, 0, fmt.Errorf( - "pinning the %s module's image broke its manifest: %w", ControlPlaneModule, err) + "pinning the %s module's image broke its manifest: %w", module, err) } if bytes.Contains(pinned, []byte(placeholderDigest)) { return nil, 0, fmt.Errorf( "the %s module's manifest still carries a placeholder digest after pinning", - ControlPlaneModule) + module) } return pinned, places, nil } diff --git a/internal/bootstrap/control_test.go b/internal/bootstrap/control_test.go index e4b0d6f..8f5a27d 100644 --- a/internal/bootstrap/control_test.go +++ b/internal/bootstrap/control_test.go @@ -63,7 +63,7 @@ const pushedReference = "127.0.0.1:5000/mesh-controller@sha256:" + // with no registry in front — which a runtime would go to the internet for, and this mesh's // control plane exists in no public registry by design. func TestTheControlPlaneIsPinnedToWhatThisMeshsRegistryAssigned(t *testing.T) { - pinned, places, err := pinImage([]byte(theControlPlaneModule), pushedReference) + pinned, places, err := pinImage([]byte(theControlPlaneModule), pushedReference, "mesh-controller") if err != nil { t.Fatal(err) } @@ -85,7 +85,7 @@ func TestTheControlPlaneIsPinnedToWhatThisMeshsRegistryAssigned(t *testing.T) { func TestAManifestAlreadyPinnedByHandIsRefused(t *testing.T) { already := strings.Replace(theControlPlaneModule, placeholderDigest, "sha256:"+strings.Repeat("9", 64), 1) - if _, _, err := pinImage([]byte(already), pushedReference); err == nil { + if _, _, err := pinImage([]byte(already), pushedReference, "mesh-controller"); err == nil { t.Fatal("a manifest already pinned to some other image was accepted") } } @@ -100,7 +100,7 @@ func TestEveryPlaceTheManifestNamesTheImageIsPinned(t *testing.T) { {"id": "migrate", "type": "container", "name": "mesh-controller-migrate", "run-once": true, "image": "mesh-controller@`+placeholderDigest+`", "args": ["migrate"]},`, 1) - pinned, places, err := pinImage([]byte(twice), pushedReference) + pinned, places, err := pinImage([]byte(twice), pushedReference, "mesh-controller") if err != nil { t.Fatal(err) } diff --git a/internal/bootstrap/phase3.go b/internal/bootstrap/phase3.go new file mode 100644 index 0000000..6ccc82a --- /dev/null +++ b/internal/bootstrap/phase3.go @@ -0,0 +1,205 @@ +package bootstrap + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/novox/mesh-host/internal/declaration" +) + +// Phase three — nothing is special after installation (novox/hq issue 051). +// +// Genesis raises a store and a broker before any module system exists, because the control plane +// cannot ask provisioning for the store it keeps its own records in or the broker it is reached over +// (novox/hq ADR 0006). That leaves two servers behind: the foundation's, and a second one the +// `postgres`/`lavinmq` modules used to raise for other modules to use. This turns the foundation's +// own servers into those modules, so a mesh runs ONE postgres and ONE lavinmq — the control plane's +// contexts and every module's database in the same server (WBS 3.1/3.2). +// +// **Adopted in place, not replaced.** The control plane is stateless and is swapped for a fresh +// container (control.go); the store and broker hold the mesh's memory and its bus, so they are kept. +// The module declares a container with the same name, image and spec the foundation raised, and the +// applier — which keys on the container name and compares a spec digest (mesh-host internal/apply) — +// finds it already running and leaves it be. The image is pinned to the one the foundation is +// running, read from the bundle this installer produced, so the two specs are the same digest and +// nothing is recreated. A recreate happens only on a real upgrade, which is where a stated window +// belongs (WBS 3.3). + +// StoreID and BrokerID are what the foundation bundle calls the two servers it raises; the modules +// that adopt them are found by these ids in the bundle this installer produced, the same way the +// control plane's own container is (ControlPlaneID). +const ( + StoreID = "store" + BrokerID = "broker" +) + +// InstallStore makes the foundation's store the `postgres` module, adopted in place. +// +// The order is InstallFromCatalogue's, with two additions the store needs and an ordinary provider +// does not: the server image is pinned to the one the foundation is already running (so the module's +// container is the same spec and is adopted, not a second one raised), and the superuser password — +// the foundation's, made at genesis — is carried in through `secret accept`, because the mesh cannot +// invent a credential that already created the databases (the same reasoning as the control plane's +// store connections, control.go deliverStores). +func InstallStore(ctx context.Context, o Options, control controlPlane, + foundation *declaration.Declaration, say func(string)) error { + + const module = "postgres" + manifest, err := readManifest(o.Catalogue, module) + if err != nil { + return err + } + + store, err := storeIn(foundation) + if err != nil { + return err + } + + // The module adopts the running store rather than raising a second one, so its server container + // has to BE the foundation's — same name, same image. The applier keys on the name and compares + // a spec digest (internal/apply), so a mismatch here would not adopt the mesh's memory but + // replace it. Checked before anything is registered, so a drift between the two pinned upstream + // images (the catalogue's and the foundation bundle's) fails fast and by name, rather than + // surfacing as the mesh's store being torn down and recreated. + // + // Not rewritten to the foundation's: the server image travels through the builder, which reads + // the manifest from the repository and leaves a concrete image alone but would carry a rewrite + // nowhere. The two are kept equal at the source — one pinned postgres, named in both places. + if err := serverMatchesFoundation(manifest, store, module); err != nil { + return err + } + say(" adopting " + store.Name + " — the store the foundation raised, unchanged") + + 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 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 + " (the provisioner; the server is adopted, not built)") + 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 { + 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 + } + + // The superuser is the foundation's, made at genesis — carried in before the push, or the push + // would seal random bytes where a working password has to be and the provisioner would not open + // the store it is meant to manage. + if err := deliverSuperuser(ctx, o, control, module, store, say); err != nil { + return err + } + + if _, err := pushNode(ctx, o, control, say); err != nil { + return err + } + say(" adopted mesh-store — the foundation's store is now the " + module + " module") + return nil +} + +// storeIn finds the store container in the bundle this installer produced. +func storeIn(d *declaration.Declaration) (*declaration.Container, error) { + return foundationContainer(d, StoreID, "store") +} + +// brokerIn finds the broker container in the bundle this installer produced. +func brokerIn(d *declaration.Declaration) (*declaration.Container, error) { + return foundationContainer(d, BrokerID, "broker") +} + +func foundationContainer(d *declaration.Declaration, id, what string) (*declaration.Container, error) { + for _, r := range d.Resources { + if r.Identity() != id { + continue + } + container, ok := r.(*declaration.Container) + if !ok { + return nil, fmt.Errorf( + "this bundle's %q is a %s, not a container, so the %s module has nothing to adopt", + id, r.Kind(), what) + } + return container, nil + } + return nil, fmt.Errorf( + "this bundle names no %q, so there is no %s for a module to adopt. It declares: %s", + id, what, strings.Join(identities(d), ", ")) +} + +// serverMatchesFoundation checks that the module's adopting container is the one the foundation +// raised — same name, same image — so the applier reconciles it in place rather than replacing it. +func serverMatchesFoundation(manifest []byte, store *declaration.Container, module string) error { + var m struct { + Resources []struct { + Type string `json:"type"` + Name string `json:"name"` + Image string `json:"image"` + } `json:"resources"` + } + if err := json.Unmarshal(manifest, &m); err != nil { + return fmt.Errorf("the %s module's manifest is not readable: %w", module, err) + } + for _, r := range m.Resources { + if r.Type != "container" || r.Name != store.Name { + continue + } + if r.Image != store.Image { + return fmt.Errorf( + "the %s module's %q container is pinned to %q, and the foundation is running %q.\n"+ + "The module adopts the foundation's store in place, so the two must name the same "+ + "image — a different one would tear down the mesh's store and raise a new one on "+ + "its data. Pin both to the same postgres image", + module, store.Name, r.Image, store.Image) + } + return nil + } + return fmt.Errorf( + "the %s module declares no container named %q, so it has nothing to adopt the foundation's "+ + "store with. Its server container has to carry the name the foundation raised", + module, store.Name) +} + +// deliverSuperuser carries the store's superuser password into the module. +// +// It is the foundation's, set on the bundle's store container at genesis; the mesh cannot invent a +// credential that already made the databases, so it goes in through `secret accept`, exactly as the +// control plane's store connections do (control.go deliverStores). +func deliverSuperuser(ctx context.Context, o Options, control controlPlane, module string, + store *declaration.Container, say func(string)) error { + + const secret = "superuser" + value := strings.TrimSpace(store.Env["POSTGRES_PASSWORD"]) + if value == "" { + return fmt.Errorf( + "the foundation's store names no POSTGRES_PASSWORD, so the %s module has no superuser "+ + "to open it with — and the mesh cannot invent the one that already made the databases", + module) + } + at := "/accepting-" + secret + if err := control.carrying(ctx, "mesh-accepting-"+secret, []byte(value), at); err != nil { + return err + } + if _, err := control.tell(ctx, "secret", "accept", o.Node, module, secret, "--from", at); err != nil { + return err + } + say(" accepted " + secret + " — the store's superuser, as the foundation made it") + return nil +} From 1232031fb950ee5b693611cee48dd197f4a7c8d3 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 16 Sep 2026 21:20:00 +0200 Subject: [PATCH 11/12] =?UTF-8?q?Correct=20store=20phrasing=20=E2=80=94=20?= =?UTF-8?q?a=20module=20gets=20a=20database=20only=20if=20it=20asks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not "every module's database"; a module requests one via requires postgres-database. The one server holds the controller's contexts and the database of each module that asks for one. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- internal/bootstrap/bootstrap.go | 3 ++- internal/bootstrap/phase3.go | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 3b7013f..a640b5f 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -589,7 +589,8 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro // ---- 14. store ------------------------------------------------------------------------ // The foundation's own store, ADOPTED as the `postgres` module (novox/hq issue 051): one - // server holds the control plane's contexts and every module's database, rather than the + // server holds the control plane's contexts and the database of each module that asks for one, + // rather than the // foundation's store beside a second one a module raised. Adopted in place — the module names // the container the foundation is already running, and the applier leaves it be (phase3.go). say("store — the foundation's store, adopted as the postgres module: one server, not two") diff --git a/internal/bootstrap/phase3.go b/internal/bootstrap/phase3.go index 6ccc82a..a74ce76 100644 --- a/internal/bootstrap/phase3.go +++ b/internal/bootstrap/phase3.go @@ -16,7 +16,7 @@ import ( // (novox/hq ADR 0006). That leaves two servers behind: the foundation's, and a second one the // `postgres`/`lavinmq` modules used to raise for other modules to use. This turns the foundation's // own servers into those modules, so a mesh runs ONE postgres and ONE lavinmq — the control plane's -// contexts and every module's database in the same server (WBS 3.1/3.2). +// contexts and the database of each module that asks for one, in the same server (WBS 3.1/3.2). // // **Adopted in place, not replaced.** The control plane is stateless and is swapped for a fresh // container (control.go); the store and broker hold the mesh's memory and its bus, so they are kept. From 56124c38b7bc804d10c954d660d4a3c93beb8ac8 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 16 Sep 2026 21:24:13 +0200 Subject: [PATCH 12/12] Phase 3.2: the foundation broker binds amqp (5672) mesh-wide for consumers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Like the store, the amqp provision is plaintext 5672 (vhost-per-login), so a consumer must reach it — bind 0.0.0.0 (firewall-gated to `mesh`, WireGuard- encrypted on the wire) instead of loopback. amqps (5671) was already mesh-wide; management (15672) stays loopback for the host-networked provisioner. Issue 051 (WBS 3.2). Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- examples/foundation-first-node.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/foundation-first-node.lock b/examples/foundation-first-node.lock index d5bfdd1..cbdf3e3 100644 --- a/examples/foundation-first-node.lock +++ b/examples/foundation-first-node.lock @@ -119,7 +119,7 @@ "type": "container", "name": "mesh-broker", "image": "192.0.2.250:5000/cloudamqp/lavinmq@sha256:b117c254e6e269a29db479e6b410ca4e46e035b4981e49d24b159673ef09d336", - "ports": ["5671:5671", "127.0.0.1:5672:5672", "127.0.0.1:15672:15672"], + "ports": ["5671:5671", "5672:5672", "127.0.0.1:15672:15672"], "volumes": ["mesh-broker-data:/var/lib/lavinmq", "mesh-broker-tls:/tls:ro"], "args": ["--amqps-port=5671", "--cert=/tls/tls.crt", "--key=/tls/tls.key"] },