From cdf30349a78647bd859aac36564b1d72f047d89b Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 7 Oct 2026 19:17:08 +0200 Subject: [PATCH 1/3] Keep a recorded module at the build its machine runs on every send but a person's push, and say what a send recreates (hq issue 294, ADR 0242) A plan's gated send for mail carried postgres's and mongodb's new builds (policy record) to the control node and the anchor on 2026-10-07: a send composes the machine's whole declaration from the builds the mesh holds, and the gate only ever looked at modules that roll out. Every send but a person's push now composes a recorded module from the manifest of the build the machine was last sent and records that it still carries it; the bus step moves the bus alone. And each move a gated send carries says how many of the module's containers it recreates, and whether with a new image or only their declaration. --- cmd/mesh-controller/plan.go | 16 ++ cmd/mesh-controller/push.go | 7 + cmd/mesh-controller/recorded_kept.go | 129 +++++++++++++ cmd/mesh-controller/recorded_kept_test.go | 223 ++++++++++++++++++++++ cmd/mesh-controller/release.go | 39 ++++ cmd/mesh-controller/release_plan.go | 16 ++ internal/catalogue/recreates.go | 111 +++++++++++ internal/inventory/gate.go | 35 ++++ internal/inventory/plans.go | 4 + 9 files changed, 580 insertions(+) create mode 100644 cmd/mesh-controller/recorded_kept.go create mode 100644 cmd/mesh-controller/recorded_kept_test.go create mode 100644 internal/catalogue/recreates.go diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index e531afd..f159ccf 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -127,6 +127,12 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso return catalogue.Resolution{}, nil, err } + // A recorded module is composed at the build this machine runs, on any send but a person's push + // (novox/hq issue 294, ADR 0242). + if _, err := keepRecorded(ctx, open, nodeName, shelf); err != nil { + return catalogue.Resolution{}, nil, err + } + resolved, err := catalogue.Resolve(shelf, assigned, catalogue.Node{Name: nodeName, Site: site, Capabilities: capabilities, At: onNetwork[nodeName], PublicDomain: publicDomain, @@ -433,6 +439,16 @@ func declarationWith(ctx context.Context, open *stores, node string, names = append(names, m.Module) } out.Builds = carriedBuilds(names, composed.LeftOut, current, before) + // A recorded module kept at the build the machine runs is recorded as carrying that one (ADR 0242). + kept, err := recordedKept(ctx, open, node) + if err != nil { + return sendable{}, err + } + for m, was := range kept { + if _, carried := out.Builds[m]; carried { + out.Builds[m] = was + } + } out.Bindings = boundToData(plan, composed.LeftOut) } return out, nil diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index e2ba9a5..dd8aa71 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -1082,6 +1082,13 @@ func sendToEach(ctx context.Context, open *stores, names []string) ([]string, er return nil, err } + // **A recorded build moves only by a person's push** (novox/hq issue 294, ADR 0242): this send — a + // plan's, a release plan's, a rollback's, a healer's, a rotation's — composes every recorded module at + // the build its machine runs. The bus step is a person's word for the bus alone. + if ctx, err = sendKeeps(ctx, inv); err != nil { + return nil, err + } + // Held from composing to sending (novox/hq ADR 0100); a caller that holds them already — // converge, which flips the node and then sends it — is not made to wait on itself. ctx, release, err := holdNodes(ctx, open, names) diff --git a/cmd/mesh-controller/recorded_kept.go b/cmd/mesh-controller/recorded_kept.go new file mode 100644 index 0000000..6e232a6 --- /dev/null +++ b/cmd/mesh-controller/recorded_kept.go @@ -0,0 +1,129 @@ +package main + +import ( + "context" + "fmt" + "sort" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/inventory" +) + +// A recorded build reaches a machine only by a person's push (novox/hq issue 294, ADR 0242). +// +// **A send carries the machine's whole declaration** (ADR 0221), composed from the build the mesh holds +// of every module on it. A module whose upgrade policy records — postgres, mongodb, keycloak, the +// network path — has its new build registered at its merge and sent nowhere, "until a person pushes". +// But every other send to its machine composed it too: on 2026-10-07 a catalogue merge adopting the +// images' health checks rebuilt postgres and mongodb with the rest, and the plan's gated send to the +// control node for mail — and to the anchor for the spreadsheet app — carried both, recreating the +// providers every consumer on those machines drops with. No gate judged them (the gate judges only what +// rolls out), and nobody had pushed. +// +// So **every send but a person's push composes a recorded module at the build its machine was last +// sent**: the manifest of that build, from the build records, in place of the one the mesh holds. The +// machine runs what it ran; the send records that it still carries that build; `status` keeps saying +// the machine is behind, and `push ` — a person's word — sends the new one. The bus step is a +// person's too, and carries the bus; any other recorded module waiting on the bus's machine stays. +// +// A recorded module the machine was never sent (a new assignment) is composed as the mesh holds it — +// there is nothing running to keep. One whose kept build is no longer in the records refuses the send, +// said: composing the new build would be the very move this exists to stop. + +type keepRecordedKey struct{} + +// sendKeeps is the context a send that is not a person's push composes under: every recorded module +// kept at the build its machine runs — except, on the bus step, the bus. +func sendKeeps(ctx context.Context, inv *inventory.Inventory) (context.Context, error) { + if !busStepSending(ctx) { + return keepingRecorded(ctx), nil + } + bus, err := pendingBus(ctx, inv) + if err != nil { + return nil, err + } + return keepingRecorded(ctx, bus.module), nil +} + +// keepingRecorded is a context whose sends compose every recorded module at the build its machine runs, +// except the modules named (the bus, on the bus step). +func keepingRecorded(ctx context.Context, except ...string) context.Context { + skip := map[string]bool{} + for _, m := range except { + skip[m] = true + } + return context.WithValue(ctx, keepRecordedKey{}, skip) +} + +// keptExcept is whether this context keeps recorded modules, and the modules it lets move. +func keptExcept(ctx context.Context) (map[string]bool, bool) { + skip, on := ctx.Value(keepRecordedKey{}).(map[string]bool) + return skip, on +} + +// recordedKept is, for a send under keepingRecorded, every recorded module the machine was last sent a +// build of that the mesh's build is not identical to: module → the commit it keeps. Nil when the context +// keeps nothing, or when what the machine was last sent is not known (it is then held whole elsewhere — +// ADR 0221). +func recordedKept(ctx context.Context, open *stores, node string) (map[string]string, error) { + skip, on := keptExcept(ctx) + if !on { + return nil, nil + } + inv := open.inventory + sent, known, err := inv.SentBuilds(ctx, node) + if err != nil || !known { + return nil, err + } + current, err := inv.CurrentBuilds(ctx) + if err != nil { + return nil, err + } + var f *moveFacts + out := map[string]string{} + for m, was := range sent { + now, held := current[m] + if !held || now.RollOut || skip[m] || was == "" || sameCommit(was, now.Commit) { + continue + } + if f == nil { + read, err := readMoveFacts(ctx, inv) + if err != nil { + return nil, err + } + f = &read + } + if f.identical(m, was, now.Commit) { + continue + } + out[m] = was + } + return out, nil +} + +// keepRecorded puts, in a shelf about to be resolved for a machine, the build each recorded module there +// runs in place of the one the mesh holds; it answers what it kept, module → commit. +func keepRecorded(ctx context.Context, open *stores, node string, shelf map[string]catalogue.Manifest) (map[string]string, error) { + kept, err := recordedKept(ctx, open, node) + if err != nil || len(kept) == 0 { + return nil, err + } + names := make([]string, 0, len(kept)) + for m := range kept { + names = append(names, m) + } + sort.Strings(names) + for _, m := range names { + ran, found, err := open.inventory.ManifestAt(ctx, m, kept[m]) + if err != nil { + return nil, err + } + if !found { + return nil, fmt.Errorf("%s records rather than rolls out, and %s runs its build %s, which the build "+ + "records no longer hold: this send cannot keep it and does not move it — `push %s` sends the new "+ + "one on a person's word (novox/hq ADR 0242)", m, node, short(kept[m]), node) + } + shelf[m] = ran + } + return kept, nil +} diff --git a/cmd/mesh-controller/recorded_kept_test.go b/cmd/mesh-controller/recorded_kept_test.go new file mode 100644 index 0000000..95c9b24 --- /dev/null +++ b/cmd/mesh-controller/recorded_kept_test.go @@ -0,0 +1,223 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// A recorded build reaches a machine only by a person's push (novox/hq issue 294, ADR 0242). + +// aContainerBuild is a build outcome of a module of containers, each named by id with the image and the +// health it is given; a policy when one is said. +func aContainerBuild(t *testing.T, module, commit, policy string, asked time.Time, containers map[string][2]string) link.BuildResult { + t.Helper() + var resources []any + for id, c := range containers { + r := map[string]any{"id": id, "type": "container", "name": module + "-" + id, "image": c[0]} + if c[1] != "" { + r["health"] = map[string]any{"kind": c[1]} + } + resources = append(resources, r) + } + m := map[string]any{"module": module, "version": "1", "resources": resources} + if policy != "" { + m["upgrade"] = map[string]any{"policy": policy, "why": "a provider whose restart drops every consumer"} + } + manifest, _ := json.Marshal(m) + return link.BuildResult{ID: link.NewBuildID(asked), Repository: "novox/mesh-catalog", Path: "modules/" + module, + On: "anchor", Module: module, Commit: commit, Manifest: manifest, + Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}} +} + +// imageOf is the image the composed plan of a machine gives one of a module's containers. +func imageOf(t *testing.T, plan catalogue.Resolution, module, id string) string { + t.Helper() + for _, m := range plan.Modules { + if m.Module != module { + continue + } + for _, r := range m.Resources { + if r["id"] == id { + image, _ := r["image"].(string) + return image + } + } + } + t.Fatalf("%s has no container %s in the plan", module, id) + return "" +} + +// Tonight's case, 2026-10-07: a catalogue merge adopting the images' own health checks rebuilt the +// database (policy record) with mail (policy roll). The plan's gated send for mail carried the +// database's new build to the control node and recreated it, unjudged, with nobody's word. A send that +// is not a person's push now composes the database at the build the machine runs and records that it +// still carries it; a person's push composes the new one; the bus step moves the bus alone. +func TestARecordedBuildIsCarriedOnlyByAPersonsPush(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + inv := open.inventory + start := time.Now().Add(-time.Hour) + pgImage := "registry.invalid:5000/postgres/server@sha256:" + strings.Repeat("a", 64) + mailImage := "registry.invalid:5000/mailu/smtp@sha256:" + strings.Repeat("c", 64) + for _, b := range []link.BuildResult{ + aContainerBuild(t, "postgres", "c1111111", catalogue.PolicyRecord, start, + map[string][2]string{"server": {pgImage, ""}}), + aContainerBuild(t, "mailu", "c1111111", "", start.Add(time.Second), + map[string][2]string{"smtp": {mailImage, ""}, "imap": {mailImage, ""}}), + } { + if _, _, err := takeIn(ctx, inv, b); err != nil { + t.Fatal(err) + } + } + for _, m := range []string{"postgres", "mailu"} { + if _, err := inv.Assign(ctx, "anchor", m); err != nil { + t.Fatal(err) + } + } + if err := inv.RecordSent(ctx, nodeID(t, open, "anchor"), "d-anchor", + map[string]string{"postgres": "c1111111", "mailu": "c1111111"}); err != nil { + t.Fatal(err) + } + // The merge: both adopt a health check; the images are the same. + for _, b := range []link.BuildResult{ + aContainerBuild(t, "postgres", "c2222222", catalogue.PolicyRecord, start.Add(time.Minute), + map[string][2]string{"server": {pgImage, "runtime"}}), + aContainerBuild(t, "mailu", "c2222222", "", start.Add(time.Minute+time.Second), + map[string][2]string{"smtp": {mailImage, "runtime"}, "imap": {mailImage, "runtime"}}), + } { + if _, _, err := takeIn(ctx, inv, b); err != nil { + t.Fatal(err) + } + } + + healthOf := func(plan catalogue.Resolution, module, id string) any { + for _, m := range plan.Modules { + for _, r := range m.Resources { + if m.Module == module && r["id"] == id { + return r["health"] + } + } + } + return nil + } + + // A plan's, a release plan's, a healer's send: the database is kept as it runs, mail moves. + kept := keepingRecorded(ctx) + plan, settings, err := planFor(kept, open, "anchor") + if err != nil { + t.Fatal(err) + } + if healthOf(plan, "postgres", "server") != nil { + t.Fatal("a send that is not a person's push composed the recorded module's new build") + } + if healthOf(plan, "mailu", "smtp") == nil { + t.Fatal("the module that rolls out was not composed at its new build") + } + if imageOf(t, plan, "postgres", "server") != pgImage { + t.Fatal("the recorded module's container lost its image") + } + gens, err := generators(kept, open) + if err != nil { + t.Fatal(err) + } + declared, err := declarationWith(kept, open, "anchor", plan, settings, gens, Allocating) + if err != nil { + t.Fatal(err) + } + if declared.Builds["postgres"] != "c1111111" || declared.Builds["mailu"] != "c2222222" { + t.Fatalf("the send records it carries %v; want postgres still at c1111111 and mailu at c2222222", declared.Builds) + } + + // A person's push: the recorded module's new build. + plan, _, err = planFor(ctx, open, "anchor") + if err != nil { + t.Fatal(err) + } + if healthOf(plan, "postgres", "server") == nil { + t.Fatal("a person's push did not compose the recorded module's new build") + } + + // The bus step moves the bus alone: a recorded module it is told it may move moves, the others stay. + plan, _, err = planFor(keepingRecorded(ctx, "postgres"), open, "anchor") + if err != nil { + t.Fatal(err) + } + if healthOf(plan, "postgres", "server") == nil { + t.Fatal("the module a send is let move was kept") + } + + // What a send composes under (sendToEach): every recorded module kept; on the bus step, the bus moves. + if under, err := sendKeeps(ctx, inv); err != nil { + t.Fatal(err) + } else if skip, on := keptExcept(under); !on || len(skip) != 0 { + t.Fatalf("an ordinary send keeps %v %v", on, skip) + } + if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "nats", Version: "2", + Provides: []catalogue.Offer{{Name: "mesh-bus"}}}, inventory.Source{Repository: "novox/mesh-catalog", + Seat: "git", Path: "modules/nats", BuiltFrom: "n2", Head: "n2"}); err != nil { + t.Fatal(err) + } + if under, err := sendKeeps(withBusStep(ctx), inv); err != nil { + t.Fatal(err) + } else if skip, on := keptExcept(under); !on || !skip["nats"] || len(skip) != 1 { + t.Fatalf("the bus step keeps %v %v; want everything recorded but the bus", on, skip) + } + + // And a recorded module whose kept build the records no longer hold refuses the send, said. + if err := inv.RecordSent(ctx, nodeID(t, open, "anchor"), "d-anchor", + map[string]string{"postgres": "c0000000", "mailu": "c2222222"}); err != nil { + t.Fatal(err) + } + if _, _, err := planFor(kept, open, "anchor"); err == nil || !strings.Contains(err.Error(), "push anchor") { + t.Fatalf("a recorded build that cannot be kept did not refuse the send with its remedy: %v", err) + } + + // And the gated send for mail says what it recreates: both containers, no new image, at once. + moves := []inventory.CarriedMove{{Module: "mailu", Node: "anchor", From: "c1111111", To: "c2222222"}} + sayRecreations(ctx, open, moves) + if !strings.Contains(moves[0].Recreates, "recreates 2 of mailu's 2") || !strings.Contains(moves[0].Recreates, "no new image") { + t.Fatalf("the send says %q of mail's containers", moves[0].Recreates) + } + if said := recreationsSaid(moves); !strings.HasPrefix(said, "on anchor recreates") { + t.Fatalf("the plan's note says %q", said) + } +} + +// The send says what it recreates (ADR 0242): mail's health checks adopted recreate both its +// containers, with no new image, every one at once. +func TestASendSaysWhatItRecreates(t *testing.T) { + image := "registry.invalid:5000/mailu/smtp@sha256:" + strings.Repeat("c", 64) + from := catalogue.Manifest{Module: "mailu", Resources: []map[string]any{ + {"id": "smtp", "type": "container", "image": image}, + {"id": "imap", "type": "container", "image": image}, + {"id": "redis", "type": "container", "image": image}, + {"id": "config", "type": "file", "path": "/etc/x"}}} + to := catalogue.Manifest{Module: "mailu", Resources: []map[string]any{ + {"id": "smtp", "type": "container", "image": image, "health": map[string]any{"kind": "runtime"}}, + {"id": "imap", "type": "container", "image": image, "health": map[string]any{"kind": "runtime"}}, + {"id": "redis", "type": "container", "image": image}, + {"id": "config", "type": "file", "path": "/etc/y"}}} + r := catalogue.Recreates(from, to) + if !r.SpecOnly() || len(r.Recreated) != 2 || r.Containers != 3 { + t.Fatalf("recreation %+v", r) + } + said := r.Say("mailu") + for _, want := range []string{"recreates 2 of mailu's 3", "no new image", "imap, smtp", "interrupted"} { + if !strings.Contains(said, want) { + t.Fatalf("%q does not say %q", said, want) + } + } + if catalogue.Recreates(from, from).Say("mailu") != "" { + t.Fatal("a move that recreates nothing said something") + } + to.Resources[2]["image"] = strings.Replace(image, "c", "d", 1) + if r := catalogue.Recreates(from, to); r.SpecOnly() || len(r.Images) != 1 { + t.Fatalf("a new image read as a declaration only: %+v", r) + } +} diff --git a/cmd/mesh-controller/release.go b/cmd/mesh-controller/release.go index 2d16ce1..680e751 100644 --- a/cmd/mesh-controller/release.go +++ b/cmd/mesh-controller/release.go @@ -301,6 +301,7 @@ func gatedSend(ctx context.Context, open *stores, node string, owns []inventory. } } sort.Slice(moves, func(i, j int) bool { return moves[i].Module < moves[j].Module }) + sayRecreations(ctx, open, moves) sent, err := sendRollout(withScope(ctx, sendScope{judged: map[string]bool{node: true}}), open, []string{node}) if err != nil { return nil, nil, err @@ -552,6 +553,9 @@ func advanceRelease(ctx context.Context, open *stores, p *inventory.Plan) (bool, } r.Gate = &inventory.PlanGate{Machines: sent, Since: &now, Carried: moves} p.Note = fmt.Sprintf("sent %s %d build(s) that waited for a gate; judging them there", node, len(moves)) + if said := recreationsSaid(moves); said != "" { + p.Note += "; " + said + } fmt.Printf("%s: %s\n", p.ID, p.Note) return true, nil } @@ -665,3 +669,38 @@ func backlogCommand(ctx context.Context, sub string, args []string) error { } return nil } + +// sayRecreations says, on each move a send carries, what it does to the module's containers (novox/hq +// ADR 0242): the build the machine ran against the one it is sent, container by container. Left unsaid +// for a move whose earlier build is not in the records. +func sayRecreations(ctx context.Context, open *stores, moves []inventory.CarriedMove) { + if len(moves) == 0 { + return + } + shelf, err := open.inventory.Catalogue(ctx) + if err != nil { + return + } + for i, mv := range moves { + to, held := shelf[mv.Module] + if !held || mv.From == "" { + continue + } + from, found, err := open.inventory.ManifestAt(ctx, mv.Module, mv.From) + if err != nil || !found { + continue + } + moves[i].Recreates = catalogue.Recreates(from, to).Say(mv.Module) + } +} + +// recreationsSaid is every recreation a send's moves say, joined: what a plan's note carries. +func recreationsSaid(moves []inventory.CarriedMove) string { + var said []string + for _, mv := range moves { + if mv.Recreates != "" { + said = append(said, fmt.Sprintf("on %s %s", mv.Node, mv.Recreates)) + } + } + return strings.Join(said, "; ") +} diff --git a/cmd/mesh-controller/release_plan.go b/cmd/mesh-controller/release_plan.go index 0fe1d45..c8d2c9c 100644 --- a/cmd/mesh-controller/release_plan.go +++ b/cmd/mesh-controller/release_plan.go @@ -904,6 +904,11 @@ func firstSend(ctx context.Context, open *stores, p *inventory.Plan, node string strings.Join(sent, ", ")) fmt.Printf("%s: tier %d built; sent %d module(s) to %s first in one send (%s), the rest once its gate passes\n", p.ID, p.Tier, len(modules), strings.Join(sent, ", "), strings.Join(modules, ", ")) + // What the send recreates, said with it (novox/hq ADR 0242). + if said := recreationsSaid(carried); said != "" { + p.Note += "; " + said + fmt.Printf("%s: %s\n", p.ID, said) + } return nil } @@ -1244,6 +1249,9 @@ func plansCommand(ctx context.Context, args []string) error { fmt.Printf(" %s\n", gateLine(r.Gate)) for _, c := range r.Gate.Carried { fmt.Printf(" %-22s %s → %s\n", c.Module, short(c.From), short(c.To)) + if c.Recreates != "" { + fmt.Printf(" %-22s %s\n", "", c.Recreates) + } } } return nil @@ -1279,6 +1287,14 @@ func plansCommand(ctx context.Context, args []string) error { case s != nil && s.Gate != nil: fmt.Printf(" %-22s %s\n", "", gateLine(s.Gate)) } + // What the send to its first machine did to its containers (ADR 0242). + if s != nil && s.Gate != nil { + for _, c := range s.Gate.Carried { + if c.Recreates != "" { + fmt.Printf(" %-22s on %s %s\n", "", c.Node, c.Recreates) + } + } + } } } return nil diff --git a/internal/catalogue/recreates.go b/internal/catalogue/recreates.go new file mode 100644 index 0000000..130ac0f --- /dev/null +++ b/internal/catalogue/recreates.go @@ -0,0 +1,111 @@ +package catalogue + +import ( + "encoding/json" + "fmt" + "reflect" + "sort" + "strings" +) + +// What a move does to a module's containers (novox/hq ADR 0242). +// +// A container whose declaration changes is recreated, whatever changed in it: an image, an environment +// variable, a health check adopted. A module whose containers are recreated in one send is down while +// they start again — on 2026-10-07 a catalogue change that only adopted the images' own health checks +// recreated nine of mail's containers at once, and the operator's phone could not reach the mail. So a +// send says, per module, how many of its containers it recreates and whether any image changed, before +// it is sent and in the plan that sent it. + +// Recreation is what moving a module from one build's manifest to another's does to its containers. +type Recreation struct { + // Containers is how many containers the new build declares. + Containers int + // Recreated are the ids of the containers whose declaration differs — changed, added or gone — + // sorted. + Recreated []string + // Images are the ids among them whose image changed (or that are added or gone). + Images []string +} + +// Recreates compares two builds of a module, container by container, by resource id. +func Recreates(from, to Manifest) Recreation { + before := containersByID(from) + after := containersByID(to) + var r Recreation + r.Containers = len(after) + for id, now := range after { + was, held := before[id] + switch { + case !held: + r.Recreated = append(r.Recreated, id) + r.Images = append(r.Images, id) + case !sameDeclaration(was, now): + r.Recreated = append(r.Recreated, id) + if !sameDeclaration(was["image"], now["image"]) { + r.Images = append(r.Images, id) + } + } + } + for id := range before { + if _, kept := after[id]; !kept { + r.Recreated = append(r.Recreated, id) + r.Images = append(r.Images, id) + } + } + sort.Strings(r.Recreated) + sort.Strings(r.Images) + return r +} + +// SpecOnly is whether the move recreates containers without any new image: a change to how they are +// declared only — a health check adopted, a variable — which a person may well not expect to interrupt. +func (r Recreation) SpecOnly() bool { return len(r.Recreated) > 0 && len(r.Images) == 0 } + +// Say is the recreation in the mesh's words, for a module: empty when the move recreates nothing. +func (r Recreation) Say(module string) string { + if len(r.Recreated) == 0 { + return "" + } + what := "with a new image" + switch { + case r.SpecOnly(): + what = "no new image, only their declaration" + case len(r.Images) < len(r.Recreated): + what = fmt.Sprintf("%d with a new image", len(r.Images)) + } + whole := "" + if len(r.Recreated) > 1 && len(r.Recreated) >= r.Containers { + whole = ", every one at once: its service is interrupted until they are up again" + } else if len(r.Recreated) > 1 { + whole = ", at once: what they serve is interrupted until they are up again" + } + return fmt.Sprintf("recreates %d of %s's %d container(s) (%s: %s)%s", len(r.Recreated), module, r.Containers, + what, strings.Join(r.Recreated, ", "), whole) +} + +func containersByID(m Manifest) map[string]map[string]any { + out := map[string]map[string]any{} + for _, r := range m.Resources { + if t, _ := r["type"].(string); t != "container" { + continue + } + id, _ := r["id"].(string) + out[id] = r + } + return out +} + +// sameDeclaration compares two declarations as JSON, so a number read as an int and one read as a +// float are one value. +func sameDeclaration(a, b any) bool { + ra, errA := json.Marshal(a) + rb, errB := json.Marshal(b) + if errA != nil || errB != nil { + return reflect.DeepEqual(a, b) + } + var na, nb any + _ = json.Unmarshal(ra, &na) + _ = json.Unmarshal(rb, &nb) + return reflect.DeepEqual(na, nb) +} diff --git a/internal/inventory/gate.go b/internal/inventory/gate.go index 9d32b07..3639008 100644 --- a/internal/inventory/gate.go +++ b/internal/inventory/gate.go @@ -307,3 +307,38 @@ func (i *Inventory) BuildOf(ctx context.Context, module, commit string) (string, } return id, err } + +// ManifestAt is the manifest a module was registered with at a commit (or a commit it abbreviates): the +// newest successful build from it, with the artifacts of the build standing for it when its source was +// unchanged (issue 280) — what a machine last sent that build runs. False when no such build, or none +// with a manifest, is kept (novox/hq issue 294: a recorded module is composed at the build its machine +// runs until a person's push moves it). +func (i *Inventory) ManifestAt(ctx context.Context, module, commit string) (catalogue.Manifest, bool, error) { + if commit == "" { + return catalogue.Manifest{}, false, nil + } + var id string + var raw []byte + err := i.store.Pool().QueryRow(ctx, + `select id, manifest from build + where module = $1 and failed = '' and manifest is not null and manifest::text <> 'null' + and (commit_hash = $2 or starts_with(commit_hash, $2)) + order by at desc limit 1`, module, commit).Scan(&id, &raw) + if errors.Is(err, pgx.ErrNoRows) { + return catalogue.Manifest{}, false, nil + } + if err != nil { + return catalogue.Manifest{}, false, err + } + if stands, same, err := i.StandingBuild(ctx, module, id); err != nil { + return catalogue.Manifest{}, false, err + } else if stands != "" && stands != id && len(same) > 0 { + raw = same + } + m, err := catalogue.ParseManifest(raw) + if err != nil { + return catalogue.Manifest{}, false, fmt.Errorf("%s's build from %s is not a manifest the mesh can compose: %w", + module, commit, err) + } + return m, true, nil +} diff --git a/internal/inventory/plans.go b/internal/inventory/plans.go index 34e74f9..b3ceed4 100644 --- a/internal/inventory/plans.go +++ b/internal/inventory/plans.go @@ -152,6 +152,10 @@ type CarriedMove struct { From string `json:"from,omitempty"` To string `json:"to"` Build string `json:"build,omitempty"` + // Recreates is what the send does to the module's containers, in the mesh's words — how many it + // recreates, and whether with a new image or only their declaration (novox/hq ADR 0242); empty when + // it recreates none, or when the build the machine ran is not known. + Recreates string `json:"recreates,omitempty"` } // PlanRelease is a release plan's walk through the machines (novox/hq ADR 0236): every module build From 5efe999733706db1157cdaf90c7b5c2b292a9494 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 7 Oct 2026 19:28:54 +0200 Subject: [PATCH 2/3] Name the hq issue by its number: 294 was taken on an open branch, this is 295 --- cmd/mesh-controller/plan.go | 2 +- cmd/mesh-controller/push.go | 2 +- cmd/mesh-controller/recorded_kept.go | 2 +- cmd/mesh-controller/recorded_kept_test.go | 2 +- internal/inventory/gate.go | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index f159ccf..2dee747 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -128,7 +128,7 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso } // A recorded module is composed at the build this machine runs, on any send but a person's push - // (novox/hq issue 294, ADR 0242). + // (novox/hq issue 295, ADR 0242). if _, err := keepRecorded(ctx, open, nodeName, shelf); err != nil { return catalogue.Resolution{}, nil, err } diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index dd8aa71..5565a8a 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -1082,7 +1082,7 @@ func sendToEach(ctx context.Context, open *stores, names []string) ([]string, er return nil, err } - // **A recorded build moves only by a person's push** (novox/hq issue 294, ADR 0242): this send — a + // **A recorded build moves only by a person's push** (novox/hq issue 295, ADR 0242): this send — a // plan's, a release plan's, a rollback's, a healer's, a rotation's — composes every recorded module at // the build its machine runs. The bus step is a person's word for the bus alone. if ctx, err = sendKeeps(ctx, inv); err != nil { diff --git a/cmd/mesh-controller/recorded_kept.go b/cmd/mesh-controller/recorded_kept.go index 6e232a6..8dbc376 100644 --- a/cmd/mesh-controller/recorded_kept.go +++ b/cmd/mesh-controller/recorded_kept.go @@ -9,7 +9,7 @@ import ( "github.com/novox/mesh-controller/internal/inventory" ) -// A recorded build reaches a machine only by a person's push (novox/hq issue 294, ADR 0242). +// A recorded build reaches a machine only by a person's push (novox/hq issue 295, ADR 0242). // // **A send carries the machine's whole declaration** (ADR 0221), composed from the build the mesh holds // of every module on it. A module whose upgrade policy records — postgres, mongodb, keycloak, the diff --git a/cmd/mesh-controller/recorded_kept_test.go b/cmd/mesh-controller/recorded_kept_test.go index 95c9b24..dbc78d6 100644 --- a/cmd/mesh-controller/recorded_kept_test.go +++ b/cmd/mesh-controller/recorded_kept_test.go @@ -11,7 +11,7 @@ import ( "github.com/novox/mesh-controller/internal/link" ) -// A recorded build reaches a machine only by a person's push (novox/hq issue 294, ADR 0242). +// A recorded build reaches a machine only by a person's push (novox/hq issue 295, ADR 0242). // aContainerBuild is a build outcome of a module of containers, each named by id with the image and the // health it is given; a policy when one is said. diff --git a/internal/inventory/gate.go b/internal/inventory/gate.go index 3639008..58bc4e8 100644 --- a/internal/inventory/gate.go +++ b/internal/inventory/gate.go @@ -311,7 +311,7 @@ func (i *Inventory) BuildOf(ctx context.Context, module, commit string) (string, // ManifestAt is the manifest a module was registered with at a commit (or a commit it abbreviates): the // newest successful build from it, with the artifacts of the build standing for it when its source was // unchanged (issue 280) — what a machine last sent that build runs. False when no such build, or none -// with a manifest, is kept (novox/hq issue 294: a recorded module is composed at the build its machine +// with a manifest, is kept (novox/hq issue 295: a recorded module is composed at the build its machine // runs until a person's push moves it). func (i *Inventory) ManifestAt(ctx context.Context, module, commit string) (catalogue.Manifest, bool, error) { if commit == "" { From 1fdff00794e9547094acb878eb6f4050112f3bd1 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 7 Oct 2026 19:36:57 +0200 Subject: [PATCH 3/3] Promise the delivery seat's checks verb, optional until its holder serves it (hq ADR 0239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What the mesh's checks said of a pull request had no verb: the verdict was read from this controller's journal. mesh-delivery answers it as checks. A verb added to a mesh seat whose holder lives in another repository deadlocked: this controller would refuse the holder that does not serve it, the one before it the holder that does, and every catalogue check between the two would fail on mesh-delivery. So a verb can be marked optional — served or not, the holder holds — until every holder serves it. --- internal/catalogue/delivery_seat.go | 12 ++++- internal/catalogue/delivery_seat_test.go | 60 ++++++++++++++++++++++++ internal/catalogue/verbs.go | 10 +++- 3 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 internal/catalogue/delivery_seat_test.go diff --git a/internal/catalogue/delivery_seat.go b/internal/catalogue/delivery_seat.go index c728653..4c62dec 100644 --- a/internal/catalogue/delivery_seat.go +++ b/internal/catalogue/delivery_seat.go @@ -9,7 +9,7 @@ package catalogue // DeliverySeat is the seat mesh-delivery holds. const DeliverySeat = "mesh-delivery" -// deliveryVerbs are the delivery seat's tools: five that read, and the acts of a person and of healer H2. +// deliveryVerbs are the delivery seat's tools: six that read, and the acts of a person and of healer H2. func deliveryVerbs() []Verb { return []Verb{ {Name: "deliveries", Description: "Every delivery not final, and those that ended in the last day, one line " + @@ -32,6 +32,16 @@ func deliveryVerbs() []Verb { Input: schema(map[string]string{"repository": "owner/repository", "paths": "the files it changes, comma-separated, from the repository's root", "base": "the branch it merges into (default main)"}, []string{"repository", "paths"})}, + {Name: "checks", Description: "What the mesh's checks said of a pull request's head or of one commit: each " + + "of the commit's mesh statuses (mesh/merge-gate, mesh/repo-check, mesh/delivery, …) with its state, " + + "description and when it was set; the merge check's full verdict as the controller said it — each " + + "layer's summary, the machine that ran it, when, its build id and its report; and whether the branch's " + + "protection would let it merge, every required status being success.", + Input: schema(map[string]string{"repository": "owner/repository", + "number": "a pull request's number: its head is read", + "commit": "a commit's sha, or the start of one, instead of a pull request"}, []string{"repository"}), + // Added after the seat's first holder shipped: optional until mesh-delivery serves it everywhere. + Optional: true}, {Name: "table", Description: "The state table every delivery runs by: each transition with its guard, " + "each state's bound and what healer H2 may do once it has passed; and the machine steps' table.", Input: schema(map[string]string{}, nil)}, diff --git a/internal/catalogue/delivery_seat_test.go b/internal/catalogue/delivery_seat_test.go new file mode 100644 index 0000000..046b93d --- /dev/null +++ b/internal/catalogue/delivery_seat_test.go @@ -0,0 +1,60 @@ +package catalogue + +import ( + "reflect" + "strings" + "testing" +) + +// The delivery seat answers "what did the mesh's checks say of this pull request?" as a verb of its own, +// `checks` (novox/hq ADR 0239): read by repository and a pull request's number or a commit. +func TestTheDeliverySeatPromisesChecks(t *testing.T) { + seat, ok := SeatNamed(DeliverySeat) + if !ok { + t.Fatal("the delivery seat is not in the set") + } + var checks *Verb + for i := range seat.Serves { + if seat.Serves[i].Name == "checks" { + checks = &seat.Serves[i] + } + } + if checks == nil { + t.Fatalf("the delivery seat promises %v and not checks", VerbNames(seat.Serves)) + } + props, _ := checks.Input["properties"].(map[string]any) + for _, arg := range []string{"repository", "number", "commit"} { + if _, has := props[arg]; !has { + t.Errorf("checks takes no %q", arg) + } + } + if req, _ := checks.Input["required"].([]string); !reflect.DeepEqual(req, []string{"repository"}) { + t.Errorf("checks requires %v, wanted only the repository", req) + } + + // Added after the seat's holder shipped, it is optional: the holder serving the ten verbs before it still + // holds the seat, and one serving all eleven does too — so the controller and the catalogue can move in + // either order, and no check of the catalogue fails on a module nobody touched between the two. + if !checks.Optional { + t.Fatal("checks is a condition of holding before its holder serves it") + } + var before []string + for _, v := range VerbNames(seat.Serves) { + if v != "checks" { + before = append(before, v) + } + } + m := Manifest{Module: "mesh-delivery", Claims: []Claim{{Name: DeliverySeat, Scope: ScopeMesh, Serves: before}}} + if err := CanHold(m, seat); err != nil { + t.Fatalf("a holder without checks yet: %v", err) + } + m.Claims[0].Serves = VerbNames(seat.Serves) + if err := CanHold(m, seat); err != nil { + t.Fatalf("a holder serving every verb: %v", err) + } + // Every other verb is still a condition of holding. + m.Claims[0].Serves = append([]string{"checks"}, before[1:]...) + if err := CanHold(m, seat); err == nil || !strings.Contains(err.Error(), before[0]) { + t.Fatalf("a holder without %s: %v", before[0], err) + } +} diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index 33e9df6..af3318f 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -22,6 +22,14 @@ type Verb struct { Description string `json:"description,omitempty"` Input map[string]any `json:"input,omitempty"` Output map[string]any `json:"output,omitempty"` + // Optional marks a verb added to a mesh seat whose holder lives in another repository (design 33 §7, + // additive within a version): a holder that serves it is accepted, and one that does not yet still + // holds the seat. Without it the addition would be a deadlock — this controller refusing the holder that + // does not serve the verb, the controller before it refusing the holder that does — and every check of + // the catalogue between the two would fail on a module nobody touched. Once every holder serves it, + // the mark is removed and the verb is a condition of holding like the rest. Never stored or said: the + // seat set's protocol is the compiled one. + Optional bool `json:"-"` } func (v *Verb) UnmarshalJSON(raw []byte) error { @@ -442,7 +450,7 @@ func unservedVerbs(tools []string, promised []Verb) []string { } var missing []string for _, v := range promised { - if !has[v.Name] { + if !has[v.Name] && !v.Optional { missing = append(missing, v.Name) } }