From 5a963aec10b4fa015aa891552fbe23b118abe09a Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 28 Sep 2026 12:43:15 +0200 Subject: [PATCH] A version prepares its state before it runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mesh derives the preparation from the module's own resource instead of each module hand-writing a step beside it (novox/hq ADR 0135). A manifest says one word — `prepares` — and the mesh runs that module's own program in its preparation mode, in the module's own context: the same image, the same environment, the same mounts, because it is the same code. A published port and a fixed address are taken away rather than copied, since the version being replaced still holds them. One word for every kind of module: a Go binary receives `prepare` as its argument, a bundle receives it through the runtime whose entry takes the same word. The control plane answers it like anything else — its own schema stops being a special case, and its hand-written step is gone. --- cmd/mesh-controller/main.go | 6 +- internal/catalogue/declaration.go | 85 ++++++++++++++++++ internal/catalogue/manifest.go | 24 +++++ internal/catalogue/prepares_test.go | 135 ++++++++++++++++++++++++++++ module.json | 25 +----- 5 files changed, 250 insertions(+), 25 deletions(-) create mode 100644 internal/catalogue/prepares_test.go diff --git a/cmd/mesh-controller/main.go b/cmd/mesh-controller/main.go index 2e5c209..28d52a1 100644 --- a/cmd/mesh-controller/main.go +++ b/cmd/mesh-controller/main.go @@ -76,7 +76,10 @@ func run() error { return pinCommand(ctx, args[1:], true) case "unpin": return pinCommand(ctx, args[1:], false) - case "migrate": + // `prepare` is how the mesh asks any module to bring its state to the shape this version needs + // (novox/hq ADR 0135), and the control plane answers it the same way as everything else — its + // own schema is not a special case. `migrate` remains the word a person types. + case "prepare", "migrate": return migrate(ctx) case "node": return nodeCommand(ctx, args[1:]) @@ -138,6 +141,7 @@ func usage() { fmt.Fprint(os.Stderr, `mesh-controller — the control plane migrate bring each context's schema up to date + prepare the same, asked the way the mesh asks any module (ADR 0135) node add [--adopted] create a node record; --adopted: the machine is in use node list the nodes this mesh knows about node show what one machine reported it can do, and why diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index fe6f76b..afd1087 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -623,6 +623,9 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri // one of them as its environment without saying so (ADR 0086, issue 041). secretFiles := secretFilesOf(resources) + // Which of this module's resources its preparation runs before, if it prepares anything. + prepareBefore := preparationTarget(m) + for _, unsettled := range resources { resource, err := ApplySettings(unsettled, with.Settings[m.Module]) if err != nil { @@ -708,6 +711,18 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri if renamed := reflectsRenamed(m.Module, resource["reload-on"]); renamed != nil { copied["reload-on"] = renamed } + // **A version prepares its state before it runs** (novox/hq ADR 0135). Derived from the + // module's own resource rather than declared beside it: what prepares the state is the + // module's own code, so what it is given has to be what that code is given — and a + // second resource written by hand is a second copy to drift from the first. Placed + // immediately before it, because a run-once step stops everything the declaration + // places after it (ADR 0052), which is how a version whose preparation failed does not + // serve. + if prepareBefore != "" && fmt.Sprint(resource["id"]) == prepareBefore { + step := prepared(copied) + owner[fmt.Sprint(step["id"])] = m.Module + out = append(out, step) + } owner[fmt.Sprint(copied["id"])] = m.Module out = append(out, copied) } @@ -1610,3 +1625,73 @@ func atMachinePort(serves map[string]any, module string, ports map[string]map[in func AtPublishedPort(values map[string]any, module string, published map[int]int) map[string]any { return atMachinePort(values, module, map[string]map[int]int{module: published}) } + +// PreparationArgument is how the mesh asks a module to prepare its state: one word, to the module's +// own program, whatever that program is (novox/hq ADR 0135). +// +// **One word for every kind of module.** A module built as a Go binary receives it as its argument; +// one built as a bundle receives it through the runtime, whose entry takes the same word. So the +// mesh has one way of asking and a module has one way of answering, and neither learns the other's +// shape. +const PreparationArgument = "prepare" + +// preparationTarget is the resource a module's preparation runs before: its own workload. +// +// The first container carrying an artifact this module built, and not itself a step — that is the +// thing that runs the module's code, and therefore the thing whose state must be ready. Empty when +// the module prepares nothing, or when nothing it declares could run its code. +// +// **A module with two own workloads gates the first of them.** Five modules in the catalogue declare +// more than one container of their own, none of them preparing anything today. If one ever does and +// its second workload shares the state, the gate is in front of the first — stated here because the +// alternative is a field asking an author to restate what the mesh can see. +func preparationTarget(m Manifest) string { + if !m.Prepares { + return "" + } + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) != "container" || !ownArtifact(r, m.Module) { + continue + } + if once, _ := r["run-once"].(bool); once { + continue + } + return fmt.Sprint(r["id"]) + } + return "" +} + +// ownArtifact is whether a resource runs something this module built, in either spelling a manifest +// may be in: naming the artifact, before a build resolved it, or carrying the reference a build +// recorded — this mesh's own store, under this module's name. +func ownArtifact(resource map[string]any, module string) bool { + if named, _ := resource["artifact"].(string); named != "" { + return true + } + image, _ := resource["image"].(string) + return strings.HasPrefix(image, ArtifactStoreScheme+module+"/") +} + +// prepared is the module's own resource as the step that prepares its state: the same image, the same +// context, run to completion with the mesh's preparation argument. +// +// Three things are taken away rather than copied, each because the step runs while the version it +// prepares for is still running. A published port cannot be bound twice, and a step that tried would +// fail for a reason that has nothing to do with the state. A fixed address cannot be held twice, for +// the same reason. And a cadence is what a step is the opposite of: a container runs once and gates, +// or on a schedule, or stays up, never two (ADR 0053). +func prepared(from map[string]any) map[string]any { + step := map[string]any{} + for k, v := range from { + step[k] = v + } + step["id"] = fmt.Sprint(from["id"]) + ".prepare" + step["name"] = fmt.Sprint(from["name"]) + "-prepare" + step["run-once"] = true + step["args"] = []any{PreparationArgument} + delete(step, "ports") + delete(step, "ip") + delete(step, "schedule") + delete(step, "reload-on") + return step +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 0a87d74..aa5af59 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -225,6 +225,19 @@ type Manifest struct { // subscription to the queue it writes to (design 29 §2). Uses []string `json:"uses,omitempty"` + // Prepares says this module has state that must be brought to the shape this version needs + // before this version runs, and that the module's own code does it (novox/hq ADR 0135). + // + // **A word, not an arrangement.** The mesh runs the module's own program in its preparation + // mode, in the module's own context — every binding, credential and setting its code receives, + // because it *is* its code. Nothing here names a container, a command, a mount or a variable: + // the module already said all of that once, and a second copy is a second thing to drift. + // + // **Declared, never inferred.** The control plane cannot read what is inside an artifact, so a + // module that ships a migration and does not say this breaks on its first upgrade. That is + // stated in the record rather than guarded here, because nothing mechanical can guard it. + Prepares bool `json:"prepares,omitempty"` + // Tools are the tools this module answers — request and reply, awaited. // // **New, and not `serves`**, which this manifest already uses for the facts a consumer needs @@ -1276,6 +1289,17 @@ func ParseManifest(raw []byte) (Manifest, error) { "program that reads what the mesh delivered and reconciles", m.Module, r["id"])) } + // **A module that prepares its state must have code the mesh can run** (novox/hq ADR 0135). The + // preparation is the module's own program in its preparation mode, so it is derived from the + // resource that runs that program — and a module declaring none has asked for something the mesh + // cannot compose. Said here, where the manifest is read, rather than by a declaration that + // quietly prepares nothing. + if m.Prepares && preparationTarget(m) == "" { + problems = append(problems, fmt.Sprintf( + "%s says it prepares its state, and declares no container running an artifact it built — "+ + "the preparation is this module's own program, so there has to be one for the mesh to "+ + "run it in", m.Module)) + } // **A run-once container is a step the host runs to completion** (novox/hq ADR 0052). It is a // boolean modifier on the container shape — the host runs the container, requires it to exit 0, // and starts whatever the declaration places after it only once it has. A value that is not a diff --git a/internal/catalogue/prepares_test.go b/internal/catalogue/prepares_test.go new file mode 100644 index 0000000..dd414c8 --- /dev/null +++ b/internal/catalogue/prepares_test.go @@ -0,0 +1,135 @@ +package catalogue + +import ( + "encoding/json" + "fmt" + "testing" +) + +// A module version prepares its state before it runs (novox/hq ADR 0135). +// +// What the mesh derives is the module's own resource, run once with one word, placed immediately in +// front of the thing it prepares for. What matters in these tests is that the derivation is a copy +// rather than a second description: the failure it replaces was a hand-written step repeating six +// fields of the resource it preceded, each free to drift from it. + +func aPreparingModule() Manifest { + return Manifest{ + Module: "gitea", + Prepares: true, + Resources: []map[string]any{ + {"id": "state", "type": "directory", "path": "/var/lib/gitea", "mode": "0700"}, + {"id": "server", "type": "container", "name": "mesh-gitea-server", + "image": "gitea/gitea@" + digest, "ports": []any{"3000:3000"}}, + {"id": "runtime", "type": "container", "name": "mesh-gitea", + "image": ArtifactStoreScheme + "gitea/runtime@" + digest, "network": "host", + "env": map[string]any{"MESH_GITEA_STATE_DIR": "/run/state"}, + "volumes": []any{"/var/lib/gitea:/run/state:ro"}, + "ports": []any{"9000:9000"}}, + }, + } +} + +func declaredFor(t *testing.T, m Manifest) []map[string]any { + t.Helper() + // The manifest as the mesh holds it: a build resolved the module's own artifact into the + // reference it recorded, which is also how the composition knows whose code a resource runs. + out, err := Resolution{Node: "anchor", Modules: []Manifest{m}}.Declaration( + Rendering{ArtifactStore: "anchor.internal:5100"}) + if err != nil { + t.Fatal(err) + } + return out +} + +func idsOf(resources []map[string]any) []string { + var ids []string + for _, r := range resources { + ids = append(ids, fmt.Sprint(r["id"])) + } + return ids +} + +// The step runs the module's own code, and comes immediately before it — not before the upstream +// server the module packages, which may be the very thing the state lives in. +func TestThePreparationRunsTheModulesOwnCodeAndComesRightBeforeIt(t *testing.T) { + out := declaredFor(t, aPreparingModule()) + ids := idsOf(out) + at := -1 + for i, id := range ids { + if id == "gitea.runtime.prepare" { + at = i + } + } + if at < 0 { + t.Fatalf("nothing prepares this module's state: %v", ids) + } + if ids[at+1] != "gitea.runtime" { + t.Fatalf("the preparation is not immediately before the module's own code: %v", ids) + } + for _, id := range ids[:at] { + if id == "gitea.runtime" { + t.Fatalf("the module's own code runs before its state is prepared: %v", ids) + } + } +} + +// It is given exactly what the module's own code is given. Asserted field by field against the +// resource it was derived from, because writing it twice is the fault this replaces. +func TestThePreparationIsGivenWhatTheModuleIsGiven(t *testing.T) { + out := declaredFor(t, aPreparingModule()) + declared := byID(out) + step, workload := declared["gitea.runtime.prepare"], declared["gitea.runtime"] + if step == nil || workload == nil { + t.Fatalf("expected both, got %v", idsOf(out)) + } + for _, field := range []string{"image", "network", "env", "volumes", "type"} { + if fmt.Sprint(step[field]) != fmt.Sprint(workload[field]) { + t.Errorf("the preparation's %s is %v and the module's is %v", field, step[field], workload[field]) + } + } + if once, _ := step["run-once"].(bool); !once { + t.Error("the preparation is not a step, so nothing waits for it and nothing is gated by it") + } + if fmt.Sprint(step["args"]) != fmt.Sprint([]any{PreparationArgument}) { + t.Errorf("the preparation is asked for as %v", step["args"]) + } + if fmt.Sprint(step["name"]) == fmt.Sprint(workload["name"]) { + t.Error("the preparation and the workload have one name, so one removes the other") + } + // A published port cannot be bound twice, and the version being replaced is still running. + if _, published := step["ports"]; published { + t.Errorf("the preparation publishes a port the running version holds: %v", step["ports"]) + } +} + +// A module that says nothing about preparing gets nothing, which is most modules. +func TestAModuleThatPreparesNothingGetsNoStep(t *testing.T) { + m := aPreparingModule() + m.Prepares = false + for _, id := range idsOf(declaredFor(t, m)) { + if id == "gitea.runtime.prepare" { + t.Fatal("a module that prepares nothing was given a preparation") + } + } +} + +// A module whose own code the mesh cannot find has nothing to ask, and saying so where the manifest +// is read beats a declaration that quietly prepares nothing. +func TestAModuleThatPreparesAndRunsNoneOfItsOwnCodeIsRefused(t *testing.T) { + m := Manifest{ + Module: "gitea", + Prepares: true, + Resources: []map[string]any{ + // Only the upstream server it packages: nothing here runs gitea's own code. + {"id": "server", "type": "container", "name": "mesh-gitea-server", "image": "gitea/gitea@" + digest}, + }, + } + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + if _, err := ParseManifest(raw); err == nil { + t.Fatal("a module that prepares its state with nothing of its own to run was accepted") + } +} diff --git a/module.json b/module.json index 880748e..f96c596 100644 --- a/module.json +++ b/module.json @@ -27,6 +27,7 @@ "bus": "/var/lib/mesh/mesh-controller/bus" }, "secrets-owner": "65534:65534", + "prepares": true, "resources": [ { "id": "mesh-state", @@ -34,30 +35,6 @@ "path": "/var/lib/mesh/mesh-controller", "mode": "0700" }, - { - "id": "migrate", - "type": "container", - "name": "mesh-controller-migrate", - "run-once": true, - "network": "host", - "args": [ - "migrate" - ], - "env": { - "MESH_STORE_INVENTORY_FILE": "/run/secrets/inventory", - "MESH_STORE_IDENTITY_FILE": "/run/secrets/identity", - "MESH_STORE_LICENCES_FILE": "/run/secrets/licences", - "MESH_STORE_INVENTORY_PORT": "${seat:mesh-store:5432}", - "MESH_STORE_IDENTITY_PORT": "${seat:mesh-store:5432}", - "MESH_STORE_LICENCES_PORT": "${seat:mesh-store:5432}" - }, - "volumes": [ - "/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" - ], - "artifact": "server" - }, { "id": "server", "type": "container",