diff --git a/cmd/mesh-controller/bindings.go b/cmd/mesh-controller/bindings.go new file mode 100644 index 0000000..ea3309b --- /dev/null +++ b/cmd/mesh-controller/bindings.go @@ -0,0 +1,140 @@ +package main + +import ( + "context" + "fmt" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/conditions" +) + +// A binding to data moves only by a person (novox/hq ADR 0232, issue 273). +// +// The resolver keeps each consumer of a provision that keeps its data at the provider it was last +// sent (catalogue/bound.go) and says what it would have moved. This is where that is said: on the +// push that composed it, and by the self-check's D12 every run, as an urgent condition naming the +// consumer, both providers and the pin that confirms the move. + +// The condition kinds of D12. +const ( + // kindBindingKept is a move the resolver refused: the consumer is still where its data is. + kindBindingKept = "binding-kept" + // kindBindingMoved is a consumer about to be sent another provider than the one on record with + // no pin naming it — what the resolver exists to make impossible, said if it ever is not. + kindBindingMoved = "binding-moved" + // kindBindingMoving is a move a pin asked for, not yet sent: a person's act, said so that the + // data is moved before the push that carries it. + kindBindingMoving = "binding-moving" +) + +// probeBindingsID is the self-check's id for this probe, and the source of what it raises. +const probeBindingsID = "D12" + +// keptObservation is the urgent condition for one refused move. +func keptObservation(k catalogue.KeptBinding) conditions.Observation { + return conditions.Observation{Scope: conditions.ScopeMachine, ID: bindingID(k.Machine, k.Consumer, k.Provision), + Token: kindBindingKept, Kind: kindBindingKept, Machine: k.Machine, Also: otherMachines(k.Machine, k.Bound.Node, k.Would.Node), + Severity: conditions.Urgent, Resolver: conditions.ResolverOperator, + Summary: fmt.Sprintf("on %s, the mesh %s", k.Machine, k.String())} +} + +func bindingID(machine, consumer, provision string) string { + return machine + "." + consumer + "." + provision +} + +func otherMachines(machine string, nodes ...string) []string { + var out []string + seen := map[string]bool{machine: true} + for _, n := range nodes { + if n != "" && !seen[n] { + seen[n] = true + out = append(out, n) + } + } + return out +} + +// reportKept says every move a machine's resolution refused, on the push composing it, and raises its +// condition at once where this process keeps the conditions: a push is when a person is looking. +func reportKept(ctx context.Context, plan catalogue.Resolution) { + for _, k := range plan.Kept { + fmt.Printf("%s: the mesh %s\n", plan.Node, k) + if conditionsFrom != nil { + o := keptObservation(k) + o.Source = probeBindingsID + if _, err := conditionsFrom.Observe(ctx, o); err != nil { + fmt.Printf("%s: and the condition for it could not be raised: %v\n", plan.Node, err) + } + } + } +} + +// probeBindings is D12: every consumer of a provision that keeps its data is bound where it was last +// sent, on every machine — the resolver kept it there (said, urgent, until a person pins), or a pin +// moves it (said, so the data goes first), and never anything else. +func probeBindings(ctx context.Context, d *doctor) ([]conditions.Observation, error) { + inv := d.open.inventory + nodes, err := inv.Nodes(ctx) + if err != nil { + return nil, err + } + var out []conditions.Observation + for _, n := range nodes { + plan, _, err := planFor(ctx, d.open, n.Name) + if err != nil { + if unresolvable(err) { + // D1 says it, with the binding that refused it when that is why. + continue + } + return nil, fmt.Errorf("%s cannot be worked out: %w", n.Name, err) + } + bound, err := inv.BindingsFor(ctx, n.Name) + if err != nil { + return nil, err + } + pins, err := inv.PinsFor(ctx, n.Name) + if err != nil { + return nil, err + } + out = append(out, bindingFindings(plan, bound, pins)...) + } + return out, nil +} + +// bindingFindings is what one machine's resolution says against its record. +func bindingFindings(plan catalogue.Resolution, bound map[string]map[string]catalogue.Chosen, + pins map[string]catalogue.Chosen) []conditions.Observation { + var out []conditions.Observation + for _, k := range plan.Kept { + out = append(out, keptObservation(k)) + } + said := map[string]bool{} + for _, need := range plan.Needs { + if !need.KeepsData || need.ByRecord { + continue + } + was, recorded := bound[need.For][need.Name] + now := catalogue.Chosen{Node: need.From, Module: need.Module} + if !recorded || was == now || (was.Module == "" && was.Node == now.Node) { + continue + } + id := bindingID(plan.Node, need.For, need.Name) + if said[id] { + continue + } + said[id] = true + o := conditions.Observation{Scope: conditions.ScopeMachine, ID: id, Machine: plan.Node, + Also: otherMachines(plan.Node, was.Node, now.Node), Resolver: conditions.ResolverOperator} + if pin, pinned := pins[need.Name]; pinned && pin.Node == now.Node && (pin.Module == "" || pin.Module == now.Module) { + o.Token, o.Kind, o.Severity = kindBindingMoving, kindBindingMoving, conditions.Warning + o.Summary = fmt.Sprintf("on %s, %s's %s moves from %s to %s at the next push, by the pin — its data "+ + "is on %s: move it first", plan.Node, need.For, need.Name, was, now, was) + } else { + o.Token, o.Kind, o.Severity = kindBindingMoved, kindBindingMoved, conditions.Urgent + o.Summary = fmt.Sprintf("on %s, %s's %s would be sent %s, and it is bound to %s, where its data is, "+ + "with no pin naming %s — a move nothing asked for", plan.Node, need.For, need.Name, now, was, now) + } + out = append(out, o) + } + return out +} diff --git a/cmd/mesh-controller/bindings_test.go b/cmd/mesh-controller/bindings_test.go new file mode 100644 index 0000000..ce693e1 --- /dev/null +++ b/cmd/mesh-controller/bindings_test.go @@ -0,0 +1,183 @@ +package main + +import ( + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/conditions" +) + +// novox/hq issue 273, ADR 0232: a consumer of a provision that keeps its data moves only by a pin. + +func storeManifests() []catalogue.Manifest { + return []catalogue.Manifest{ + {Module: "store", Version: "1", + Provides: []catalogue.Offer{{Name: "postgres-database", Scope: catalogue.ScopeMesh}}, + Claims: []catalogue.Claim{{Name: "mesh-store", Scope: catalogue.ScopeMesh}}, + Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}, + Grants: map[string]string{"postgres-database": "/var/lib/mesh/store/grants"}}, + {Module: "resolver", Version: "1", + Provides: []catalogue.Offer{{Name: "wildcard-resolution", Scope: catalogue.ScopeMesh}}, + Claims: []catalogue.Claim{{Name: "mesh-dns-resolver", Scope: catalogue.ScopeMesh}}}, + {Module: "network", Version: "1", Requires: []string{"wildcard-resolution"}}, + {Module: "board", Version: "1", Requires: []string{"postgres-database"}}, + } +} + +func need(t *testing.T, plan catalogue.Resolution, consumer, provision string) catalogue.Needed { + t.Helper() + for _, n := range plan.Needs { + if n.For == consumer && n.Name == provision { + return n + } + } + t.Fatalf("no %s for %s: %+v", provision, consumer, plan.Needs) + return catalogue.Needed{} +} + +// The incident through the stores: the laptop runs its own store and a consumer of it, the anchor's +// store holds the mesh's seat. The consumer stays beside its data, the resolver follows its seat, the +// binding is recorded as sent, and a pin — only a pin — moves it, said before the push that carries it. +func TestTheIncidentAConsumerStaysBesideItsDataUntilAPersonPinsIt(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + inv := open.inventory + if _, err := inv.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil { + t.Fatal(err) + } + for _, m := range storeManifests() { + register(t, open, m) + } + assignAll := func(pairs ...[2]string) { + for _, a := range pairs { + if _, err := assign(ctx, open, a[0], a[1]); err != nil { + t.Fatalf("assign %s %s: %v", a[0], a[1], err) + } + } + } + // The anchor's store and resolver hold the mesh's seats, on record; the laptop runs its own of each. + assignAll([2]string{"anchor", "store"}, [2]string{"anchor", "resolver"}) + for _, seat := range [][2]string{{"mesh-store", "store"}, {"mesh-dns-resolver", "resolver"}} { + if err := inv.HoldSeat(ctx, seat[0], catalogue.ScopeMesh, "anchor", seat[1]); err != nil { + t.Fatal(err) + } + } + assignAll([2]string{"laptop", "store"}, [2]string{"laptop", "resolver"}, [2]string{"laptop", "network"}, + [2]string{"laptop", "board"}) + + plan, _, err := planFor(ctx, open, "laptop") + if err != nil { + t.Fatal(err) + } + if n := need(t, plan, "board", "postgres-database"); n.From != "laptop" || n.Module != "store" || !n.KeepsData { + t.Fatalf("the consumer was bound to %s/%s (keeps data: %v); its data is beside it", n.From, n.Module, n.KeepsData) + } + if n := need(t, plan, "network", "wildcard-resolution"); n.From != "anchor" || n.KeepsData { + t.Fatalf("the resolver was bound to %s (keeps data: %v); its seat is held on anchor (issue 258)", n.From, n.KeepsData) + } + + // Sent, and recorded: only the binding to data. + bindings := boundToData(plan, nil) + if len(bindings) != 1 || bindings[0].Provider != (catalogue.Chosen{Node: "laptop", Module: "store"}) { + t.Fatalf("recorded %+v", bindings) + } + if err := inv.RecordBindings(ctx, "laptop", bindings); err != nil { + t.Fatal(err) + } + if found, err := probeBindings(ctx, &doctor{open: open}); err != nil || len(found) != 0 { + t.Fatalf("a mesh bound where it was sent: %+v, %v", found, err) + } + + // The laptop's store taken away: refused, not moved to the anchor's empty one. + if err := inv.Unassign(ctx, "laptop", "store"); err != nil { + t.Fatal(err) + } + if _, _, err := planFor(ctx, open, "laptop"); err == nil || !unresolvable(err) || + !strings.Contains(err.Error(), "board on laptop is bound to laptop/store") || + !strings.Contains(err.Error(), "pin laptop postgres-database anchor store") { + t.Fatalf("the consumer's store went and it was answered elsewhere: %v", err) + } + + // A person pins the anchor's: it moves, said before it is sent, and the record keeps where it was. + if err := inv.PinProvision(ctx, "laptop", "postgres-database", "anchor", "store"); err != nil { + t.Fatal(err) + } + plan, _, err = planFor(ctx, open, "laptop") + if err != nil { + t.Fatal(err) + } + if n := need(t, plan, "board", "postgres-database"); n.From != "anchor" { + t.Fatalf("pinned to the anchor and bound to %s", n.From) + } + found, err := probeBindings(ctx, &doctor{open: open}) + if err != nil { + t.Fatal(err) + } + if len(found) != 1 || found[0].Kind != kindBindingMoving || found[0].Severity != conditions.Warning || + !strings.Contains(found[0].Summary, "board's postgres-database moves from laptop/store to anchor/store") { + t.Fatalf("a pinned move is not said before it is sent: %+v", found) + } + if err := inv.RecordBindings(ctx, "laptop", boundToData(plan, nil)); err != nil { + t.Fatal(err) + } + all, err := inv.Bindings(ctx) + if err != nil || len(all) != 1 || all[0].MovedFrom != "laptop/store" { + t.Fatalf("%+v, %v", all, err) + } + if found, err := probeBindings(ctx, &doctor{open: open}); err != nil || len(found) != 0 { + t.Fatalf("a move sent is still said: %+v, %v", found, err) + } +} + +// What one machine's resolution says against its record. +func TestBindingFindingsSayAKeptMoveAndAMoveNothingAskedFor(t *testing.T) { + home, anchor := catalogue.Chosen{Node: "home", Module: "store"}, catalogue.Chosen{Node: "anchor", Module: "store"} + plan := catalogue.Resolution{Node: "laptop", + Kept: []catalogue.KeptBinding{{Machine: "laptop", Consumer: "board", Provision: "postgres-database", + Bound: home, Would: anchor}}, + Needs: []catalogue.Needed{ + {Name: "postgres-database", For: "board", From: "home", Module: "store", KeepsData: true}, + {Name: "postgres-database", For: "game", From: "anchor", Module: "store", KeepsData: true}, + {Name: "wildcard-resolution", For: "network", From: "anchor", Module: "resolver"}, + }} + bound := map[string]map[string]catalogue.Chosen{ + "board": {"postgres-database": home}, + "game": {"postgres-database": home}, + "network": {"wildcard-resolution": {Node: "home", Module: "resolver"}}, + } + found := bindingFindings(plan, bound, nil) + if len(found) != 2 { + t.Fatalf("found %+v", found) + } + kept, moved := found[0], found[1] + if kept.Kind != kindBindingKept || kept.Severity != conditions.Urgent || kept.Machine != "laptop" || + !strings.Contains(kept.Summary, "would move board's postgres-database from home/store to anchor/store") || + !strings.Contains(kept.Summary, "its data is on home/store") || + !strings.Contains(kept.Summary, "`pin laptop postgres-database anchor store` to confirm a move (and move the data first)") { + t.Errorf("kept: %+v", kept) + } + if moved.Kind != kindBindingMoved || moved.Severity != conditions.Urgent || + !strings.Contains(moved.Summary, "game's postgres-database would be sent anchor/store") { + t.Errorf("moved: %+v", moved) + } + if kept.Key() == moved.Key() { + t.Error("two consumers, one condition") + } +} + +// A push says the move it refused, and raises its condition at once. +func TestAPushSaysAKeptMoveAndRaisesItsCondition(t *testing.T) { + k, _ := withConditionsInMemory(t) + reportKept(t.Context(), catalogue.Resolution{Node: "laptop", Kept: []catalogue.KeptBinding{{Machine: "laptop", + Consumer: "board", Provision: "postgres-database", Bound: catalogue.Chosen{Node: "home", Module: "store"}, + Would: catalogue.Chosen{Node: "anchor", Module: "store"}}}}) + open, err := k.Open(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(open) != 1 || open[0].Kind != kindBindingKept || open[0].Severity != conditions.Urgent || + open[0].Source != probeBindingsID { + t.Fatalf("raised %+v", open) + } +} diff --git a/cmd/mesh-controller/doctor.go b/cmd/mesh-controller/doctor.go index 24149ea..310f31a 100644 --- a/cmd/mesh-controller/doctor.go +++ b/cmd/mesh-controller/doctor.go @@ -99,6 +99,9 @@ var probeRegistry = []probe{ "a plan's window", From: "the version split", Kind: "core-behind", Phase: 1, run: probeCoreBuilds}, {ID: "D11", Asserts: "no provider holds a consumer retired more than thirty days without a person deciding " + "its cleanup", From: "ADR 0230", Kind: kindCleanupWaiting, Phase: 2, run: probeRetired}, + {ID: probeBindingsID, Asserts: "every consumer of a provision that keeps its data is bound where it was last " + + "sent, or moves by a pin", From: "issue 273, ADR 0232", Kind: kindBindingMoved, + Raises: []string{kindBindingKept, kindBindingMoving}, Phase: 2, run: probeBindings}, {ID: "DW", Asserts: "the watchdogs of the signals table ran within three of their intervals", From: "ADR 0227 rule 6: the watchers are watched", Kind: "watchdogs-silent", Phase: 1, run: probeWatchdogs}, } diff --git a/cmd/mesh-controller/held_back.go b/cmd/mesh-controller/held_back.go index 2d83545..22da1e3 100644 --- a/cmd/mesh-controller/held_back.go +++ b/cmd/mesh-controller/held_back.go @@ -232,6 +232,7 @@ func composeForPush(open *stores, gens map[string]catalogue.Generator) func(held return sendable{}, err } reportUnhostable(node, plan) + reportKept(held, plan) declared, err := declarationWith(held, open, node, plan, settings, gens, Allocating) if err == nil { reportLeftOut(node, declared) diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 29d9056..78565a9 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -90,6 +90,12 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso if err != nil { return catalogue.Resolution{}, nil, err } + // Where each of its consumers of a provision that keeps data was last sent (novox/hq ADR 0232): + // a resolution that would answer one from anywhere else keeps it there, and says so. + world.Bound, err = inv.BindingsFor(ctx, nodeName) + if err != nil { + return catalogue.Resolution{}, nil, err + } onNetwork, err := whereEveryoneIs(ctx, inv, shelf) if err != nil { @@ -419,10 +425,35 @@ func declarationWith(ctx context.Context, open *stores, node string, names = append(names, m.Module) } out.Builds = carriedBuilds(names, composed.LeftOut, current, before) + out.Bindings = boundToData(plan, composed.LeftOut) } return out, nil } +// boundToData is every binding of this machine's consumers to a provision that keeps their data +// (novox/hq ADR 0232), as a send records it. Not a consumer left out of the declaration: the machine +// is not told anything new about it, so nothing about where it is bound has been sent. +func boundToData(plan catalogue.Resolution, leftOut map[string]string) []inventory.Binding { + var out []inventory.Binding + seen := map[[2]string]bool{} + for _, n := range plan.Needs { + if !n.KeepsData || n.ByRecord || n.Module == "" { + continue + } + if _, left := leftOut[n.For]; left { + continue + } + key := [2]string{n.For, n.Name} + if seen[key] { + continue + } + seen[key] = true + out = append(out, inventory.Binding{Machine: plan.Node, Consumer: n.For, Provision: n.Name, + Provider: catalogue.Chosen{Node: n.From, Module: n.Module}}) + } + return out +} + // carriedBuilds is the build of each module a declaration carries, as a send records it (novox/hq // issue 259): the module's current build for each module in it, and for a module left out of it // (ADR 0163, rule 6) the build it was last sent, since the machine keeps that one — or nothing, when diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index 72d5fc0..11b2808 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -512,6 +512,7 @@ func pushCommand(ctx context.Context, args []string) error { // healthy modules beside it are still resolved and sent. Reported so it is not silently // dropped — the remedy is to move it, and until then the rest of the node converges. reportUnhostable(node, plan) + reportKept(held, plan) unheld[node] = plan.Unheld // The private network is in here with everything else. It used to be composed separately // and prepended, which meant every machine with an address was on it and no machine could @@ -847,6 +848,17 @@ func (b overTheBus) declare(ctx context.Context, s readyNode, body []byte) (stri if err != nil { return "", err } + if len(s.declared.Bindings) > 0 { + // And where it bound each consumer of a provision that keeps its data (novox/hq ADR 0232): + // what the next resolution keeps it at. On the same outliving context as the send's record. + kept, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + err := b.open.inventory.RecordBindings(kept, s.node, s.declared.Bindings) + cancel() + if err != nil { + return "", fmt.Errorf("%s was sent its declaration, and where its consumers are bound to their "+ + "data could not be recorded: %w", s.node, err) + } + } if s.declared.BusUsers != "" { // And the user list it carried, so the next send reads whether it must go first from the // list alone (novox/hq issue 249). On the same outliving context as the send's record. @@ -1007,6 +1019,7 @@ func sendToEach(ctx context.Context, open *stores, names []string) ([]string, er continue } reportUnhostable(name, plan) + reportKept(ctx, plan) declared, err := declarationWith(ctx, open, name, plan, settings, gens, Allocating) if err != nil { refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err)) diff --git a/cmd/mesh-controller/sendable.go b/cmd/mesh-controller/sendable.go index 70eb1a8..1c2faf4 100644 --- a/cmd/mesh-controller/sendable.go +++ b/cmd/mesh-controller/sendable.go @@ -55,6 +55,9 @@ type sendable struct { // was made from — recorded with the send and never on the wire (novox/hq issue 259, ADR 0221). // Composed only on the send path; nil records that it is not known. Builds map[string]string + // Bindings is where each consumer of a provision that keeps its data is bound in this declaration + // (novox/hq ADR 0232), recorded with the send and never on the wire. Composed only on the send path. + Bindings []inventory.Binding } // adoptionEnvelope is what an adopted node is told about its mode. Taken is every module taken on diff --git a/internal/catalogue/bound.go b/internal/catalogue/bound.go new file mode 100644 index 0000000..a36dc85 --- /dev/null +++ b/internal/catalogue/bound.go @@ -0,0 +1,127 @@ +package catalogue + +import "fmt" + +// A consumer of a provision that keeps its data stays bound where its data is (novox/hq ADR 0232). +// +// **What a resolution chooses is not where a consumer's data is.** Resolving answers "which provider +// would I pick now", from the seats' holders, the pins and what is assigned where, and every one of +// those can change under a consumer without anybody meaning to move it. For a resolver that is the +// point: any provider answers alike. For a database it is the consumer's whole state: on 2026-10-05 +// one change to how a seat's holder answers (issue 258) re-bound five database consumers on one +// machine to the store on another, each was given a fresh, empty database there, and nothing warned +// for twenty hours (issue 273). Nothing was lost only because the old provider kept everything. +// +// So the mesh records where each such consumer was last sent (the store's `binding` table), and a +// resolution that would answer it from anywhere else keeps the recorded provider instead and says so. +// **Only a pin moves it**, because a pin is a person: the one act that says "answer this machine's +// consumers from there", taken knowing the data must go first. + +// KeptBinding is one consumer this resolution would have moved, and did not. +type KeptBinding struct { + // Machine is where the consumer runs, and Consumer the module there that is bound. + Machine string + Consumer string + // Provision is what it is bound for. + Provision string + // Bound is the provider it was recorded at, and still is; Would is the one the resolution chose. + Bound Chosen + Would Chosen +} + +// String is the condition's sentence: the move, where the data is, and the act that confirms it. +func (k KeptBinding) String() string { + return fmt.Sprintf("would move %s's %s from %s to %s — its data is on %s; kept there. "+ + "`pin %s %s %s %s` to confirm a move (and move the data first)", + k.Consumer, k.Provision, k.Bound, k.Would, k.Bound, k.Machine, k.Provision, k.Would.Node, k.Would.Module) +} + +// keepBound holds every need for a provision that keeps its consumers' data at the provider its +// consumer was bound to, where the resolution chose another. +// +// A need with no record is a binding being made, and is left as resolved: it is recorded when it is +// first sent. A pin naming the provider the resolution chose is a person moving it, and is left too. +// Otherwise the recorded provider answers, if it still provides the provision — beside the consumer, +// or offered from elsewhere — and the move is returned as kept. **One that no longer does is refused, +// never answered by the provider chosen**: answering it there is exactly the silent move this exists +// to stop, and the consumer's data is still wherever it was. +func keepBound(needs []Needed, catalogue map[string]Manifest, node Node, world World, + keeps map[string]bool, here func(string) bool) ([]Needed, []KeptBinding, []string) { + var kept []KeptBinding + var problems []string + refused := map[[2]string]bool{} + out := make([]Needed, 0, len(needs)) + for _, n := range needs { + if n.ByRecord || !keeps[n.Name] { + out = append(out, n) + continue + } + n.KeepsData = true + bound, recorded := world.Bound[n.For][n.Name] + chose := Chosen{Node: n.From, Module: n.Module} + if !recorded || sameProvider(bound, chose) { + out = append(out, n) + continue + } + if pin, pinned := world.Pinned[n.Name]; pinned && pin.matches(Provider{Node: chose.Node, Module: chose.Module}) { + out = append(out, n) + continue + } + held, ok, why := boundNeed(n, bound, catalogue, node, world, here) + if !ok { + if key := [2]string{n.For, n.Name}; !refused[key] { + refused[key] = true + problems = append(problems, fmt.Sprintf( + "%s on %s is bound to %s for %q, which keeps its data, and %s — it would move to %s, "+ + "which holds none of it. Move its data and say so with `pin %s %s %s %s`, or give "+ + "%s back what it provided", + n.For, node.Name, bound, n.Name, why, chose, node.Name, n.Name, chose.Node, chose.Module, + bound.Node)) + } + continue + } + out = append(out, held) + kept = append(kept, KeptBinding{Machine: node.Name, Consumer: n.For, Provision: n.Name, Bound: bound, Would: chose}) + } + return out, kept, problems +} + +// sameProvider is whether a recorded provider is the one chosen. A record naming no module (none +// is written without one, but a person's hand might) matches any module on its node. +func sameProvider(bound, chose Chosen) bool { + return bound.Node == chose.Node && (bound.Module == "" || bound.Module == chose.Module) +} + +// boundNeed is the need answered by the recorded provider, or why it cannot be. +func boundNeed(n Needed, bound Chosen, catalogue map[string]Manifest, node Node, world World, + here func(string) bool) (Needed, bool, string) { + held := Needed{Name: n.Name, For: n.For, Local: n.Local, KeepsData: true} + if bound.Node == node.Name { + m, known := catalogue[bound.Module] + if !known || !here(bound.Module) || !providesAt(m, n.Name, ScopeMesh) { + return Needed{}, false, fmt.Sprintf("%s no longer runs on %s", bound.Module, node.Name) + } + at := node.At + if at == "" { + at = "127.0.0.1" + } + held.From, held.At, held.Module = node.Name, at, m.Module + held.Serves, held.SharedOwn, held.Identity = servedByOne(m, n.Name), sharedByOne(m, n.Name), m.IdentityBoundOf(n.Name) + return held, true, "" + } + matching := bound.among(world.Offered[n.Name]) + if len(matching) != 1 { + return Needed{}, false, fmt.Sprintf("%s no longer provides it", bound) + } + p := matching[0] + if node.At == "" || p.At == "" { + return Needed{}, false, fmt.Sprintf("%s and %s are not both on the private network", node.Name, p.Node) + } + identity := DefaultIdentityBound + if pm, known := catalogue[p.Module]; known { + held.SharedOwn, _ = pm.SharedCredentialOf(n.Name) + identity = pm.IdentityBoundOf(n.Name) + } + held.From, held.At, held.Module, held.Serves, held.Identity = p.Node, p.At, p.Module, p.Serves, identity + return held, true, "" +} diff --git a/internal/catalogue/bound_test.go b/internal/catalogue/bound_test.go new file mode 100644 index 0000000..d152fff --- /dev/null +++ b/internal/catalogue/bound_test.go @@ -0,0 +1,257 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// A consumer of a provision that keeps its data stays bound where its data is (novox/hq ADR 0232, +// issue 273). +// +// The incident, exactly: a machine runs its own store and five consumers of it; the mesh's store seat +// is held by the store on another machine. Issue 258's rule — the seat's holder elsewhere answers +// before this machine's own provider — re-bound all five to the holder, each was made a fresh, empty +// database there, and nothing said so. + +var incidentConsumers = []string{"board", "listings", "workflows", "agents", "game"} + +func storeMesh() map[string]Manifest { + shelf := map[string]Manifest{ + "store": {Module: "store", Version: "1", + Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh}}, + Claims: []Claim{{Name: "mesh-store", Scope: ScopeMesh}}, + Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}, + Grants: map[string]string{"postgres-database": "/grants"}}, + "resolver": {Module: "resolver", Version: "1", + Provides: []Offer{{Name: "wildcard-resolution", Scope: ScopeMesh}}, + Claims: []Claim{{Name: "mesh-dns-resolver", Scope: ScopeMesh}}}, + "network": {Module: "network", Version: "1", Requires: []string{"wildcard-resolution"}}, + } + for _, c := range incidentConsumers { + shelf[c] = Manifest{Module: c, Version: "1", Requires: []string{"postgres-database"}} + } + return shelf +} + +// storeWorld is the rest of the mesh as the home server's plan sees it: the anchor runs a store and a +// resolver and holds both seats; the home server runs its own of each. +func storeWorld() World { + w := World{Offered: map[string][]Provider{ + "postgres-database": { + {Node: "anchor", At: "anchor.internal", Module: "store", Serves: map[string]any{"port": 5432}}, + {Node: "home", At: "home.internal", Module: "store", Serves: map[string]any{"port": 5434}}, + }, + "wildcard-resolution": { + {Node: "anchor", At: "anchor.internal", Module: "resolver"}, + {Node: "home", At: "home.internal", Module: "resolver"}, + }, + }} + w.Held = []Held{ + {Claim: "mesh-store", Scope: ScopeMesh, Node: "anchor", Module: "store"}, + {Claim: "mesh-dns-resolver", Scope: ScopeMesh, Node: "anchor", Module: "resolver"}, + } + w.Holdings = w.Held + return w +} + +var home = Node{Name: "home", At: "home.internal"} + +func homeAssigned() []string { + return append([]string{"store", "resolver", "network"}, incidentConsumers...) +} + +func boundFrom(t *testing.T, r Resolution, consumer, provision string) Needed { + t.Helper() + for _, n := range r.Needs { + if n.For == consumer && n.Name == provision { + return n + } + } + t.Fatalf("no binding of %s for %s: %+v", consumer, provision, r.Needs) + return Needed{} +} + +func TestTheIncidentAMachinesOwnStoreKeepsItsConsumersWhenTheSeatIsHeldElsewhere(t *testing.T) { + got, err := Resolve(storeMesh(), homeAssigned(), home, storeWorld()) + if err != nil { + t.Fatal(err) + } + for _, c := range incidentConsumers { + if n := boundFrom(t, got, c, "postgres-database"); n.From != "home" || n.Module != "store" { + t.Errorf("%s bound to %s/%s; its data is on the store beside it (issue 273)", c, n.From, n.Module) + } + } + // And the resolver, which keeps nothing of anybody's, still answers from the seat's holder (258). + if n := boundFrom(t, got, "network", "wildcard-resolution"); n.From != "anchor" { + t.Errorf("the resolver bound to %s; the seat is held on anchor (issue 258)", n.From) + } + if len(got.Kept) != 0 { + t.Errorf("nothing was moved, and %d kept: %+v", len(got.Kept), got.Kept) + } +} + +func TestTheIncidentWithEveryConsumerOnRecordStillMovesNothing(t *testing.T) { + w := storeWorld() + w.Bound = map[string]map[string]Chosen{} + for _, c := range incidentConsumers { + w.Bound[c] = map[string]Chosen{"postgres-database": {Node: "home", Module: "store"}} + } + got, err := Resolve(storeMesh(), homeAssigned(), home, w) + if err != nil { + t.Fatal(err) + } + for _, c := range incidentConsumers { + if n := boundFrom(t, got, c, "postgres-database"); n.From != "home" { + t.Errorf("%s bound to %s", c, n.From) + } + } + if len(got.Kept) != 0 { + t.Errorf("kept %+v", got.Kept) + } +} + +func TestAPinElsewhereStillMovesAStoresConsumers(t *testing.T) { + w := storeWorld() + w.Pinned = map[string]Chosen{"postgres-database": {Node: "anchor", Module: "store"}} + w.Bound = map[string]map[string]Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}} + got, err := Resolve(storeMesh(), homeAssigned(), home, w) + if err != nil { + t.Fatal(err) + } + if n := boundFrom(t, got, "board", "postgres-database"); n.From != "anchor" || n.Module != "store" { + t.Errorf("bound to %s/%s; a person pinned it to anchor", n.From, n.Module) + } + if len(got.Kept) != 0 { + t.Errorf("a pin is a person's move, not one to keep: %+v", got.Kept) + } +} + +// A consumer on a machine with no store of its own, bound to one store, when the seat moves to +// another: the holder would answer, and the binding stays. +func laptopWorld(holder string) World { + w := storeWorld() + w.Held = []Held{{Claim: "mesh-store", Scope: ScopeMesh, Node: holder, Module: "store"}} + w.Holdings = w.Held + return w +} + +var laptop = Node{Name: "laptop", At: "laptop.internal"} + +func TestABoundConsumerStaysWhenTheSeatsHolderChanges(t *testing.T) { + w := laptopWorld("home") + w.Bound = map[string]map[string]Chosen{"board": {"postgres-database": {Node: "anchor", Module: "store"}}} + got, err := Resolve(storeMesh(), []string{"board"}, laptop, w) + if err != nil { + t.Fatal(err) + } + n := boundFrom(t, got, "board", "postgres-database") + if n.From != "anchor" || n.At != "anchor.internal" || n.Serves["port"] != 5432 { + t.Fatalf("bound to %s at %s %v; its data is on anchor", n.From, n.At, n.Serves) + } + if len(got.Kept) != 1 { + t.Fatalf("the move was not said: %+v", got.Kept) + } + k := got.Kept[0] + if k.Bound != (Chosen{"anchor", "store"}) || k.Would != (Chosen{"home", "store"}) || k.Consumer != "board" { + t.Fatalf("kept %+v", k) + } + for _, want := range []string{"would move board's postgres-database from anchor/store to home/store", + "its data is on anchor/store", "pin laptop postgres-database home store", "move the data first"} { + if !strings.Contains(k.String(), want) { + t.Errorf("%q does not say %q", k.String(), want) + } + } + // Without a record it is a binding being made, and the holder answers it as before. + w.Bound = nil + got, err = Resolve(storeMesh(), []string{"board"}, laptop, w) + if err != nil { + t.Fatal(err) + } + if n := boundFrom(t, got, "board", "postgres-database"); n.From != "home" { + t.Errorf("a new consumer bound to %s; the seat is held on home", n.From) + } +} + +func TestABoundConsumerWhoseProviderIsGoneIsRefusedNotMoved(t *testing.T) { + w := laptopWorld("anchor") + w.Offered["postgres-database"] = w.Offered["postgres-database"][:1] // only anchor's store is left + w.Bound = map[string]map[string]Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}} + _, err := Resolve(storeMesh(), []string{"board"}, laptop, w) + if err == nil { + t.Fatal("bound to home's store, which is gone, and answered by anchor's — the silent move") + } + for _, want := range []string{"board on laptop is bound to home/store", "home/store no longer provides it", + "pin laptop postgres-database anchor store"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("%q does not say %q", err, want) + } + } +} + +func TestAMachinesOwnStoreUnassignedRefusesItsBoundConsumers(t *testing.T) { + w := storeWorld() + w.Bound = map[string]map[string]Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}} + _, err := Resolve(storeMesh(), []string{"board"}, home, w) + if err == nil || !strings.Contains(err.Error(), "store no longer runs on home") { + t.Fatalf("got %v", err) + } +} + +// The first pass asks only what a machine offers, and is never refused by a binding. +func TestTheFirstPassKeepsNoBinding(t *testing.T) { + w := storeWorld() + w.Unchecked = true + w.Bound = map[string]map[string]Chosen{"board": {"postgres-database": {Node: "gone", Module: "store"}}} + if _, err := Resolve(storeMesh(), []string{"board"}, laptop, w); err != nil { + t.Fatal(err) + } +} + +func TestAnOfferSaysWhetherItKeepsConsumerData(t *testing.T) { + var o Offer + if err := json.Unmarshal([]byte(`{"name":"wildcard-resolution","scope":"mesh","keeps-consumer-data":false}`), &o); err != nil { + t.Fatal(err) + } + if o.KeepsConsumerData == nil || *o.KeepsConsumerData { + t.Fatalf("read %+v", o) + } + out, err := json.Marshal(o) + if err != nil || !strings.Contains(string(out), `"keeps-consumer-data":false`) { + t.Fatalf("wrote %s, %v", out, err) + } + yes, no := true, false + granting := Manifest{Module: "g", Provides: []Offer{{Name: "p", Scope: ScopeMesh}}, Grants: map[string]string{"p": "/g"}} + if !granting.KeepsConsumerData("p") { + t.Error("a provider granting each consumer a credential keeps what it writes, unsaid") + } + if (Manifest{Module: "r", Provides: []Offer{{Name: "p", Scope: ScopeMesh}}}).KeepsConsumerData("p") { + t.Error("a provider granting nothing keeps nothing, unsaid") + } + granting.Provides[0].KeepsConsumerData = &no + if granting.KeepsConsumerData("p") { + t.Error("what an offer says, it gets") + } + said := Manifest{Module: "s", Provides: []Offer{{Name: "p", Scope: ScopeMesh, KeepsConsumerData: &yes}}} + if !said.KeepsConsumerData("p") || !KeepsConsumerData(map[string]Manifest{"s": said, "r": {Module: "r", + Provides: []Offer{{Name: "p", Scope: ScopeMesh, KeepsConsumerData: &no}}}}, "p") { + t.Error("a name any provider says keeps data keeps data") + } +} + +// An offer saying it keeps nothing is answered by the seat's holder as the resolver is, even where it +// grants a credential. +func TestAStoreSayingItKeepsNothingFollowsTheSeat(t *testing.T) { + shelf := storeMesh() + no := false + s := shelf["store"] + s.Provides = []Offer{{Name: "postgres-database", Scope: ScopeMesh, KeepsConsumerData: &no}} + shelf["store"] = s + got, err := Resolve(shelf, homeAssigned(), home, storeWorld()) + if err != nil { + t.Fatal(err) + } + if n := boundFrom(t, got, "board", "postgres-database"); n.From != "anchor" { + t.Errorf("bound to %s; it keeps nothing, and the seat is held on anchor", n.From) + } +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index aeb16a5..394dd4d 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -159,6 +159,11 @@ type Offer struct { // from its consumer. Unsaid, the mesh assumes the tightest backend it knows when the provider is // told its consumers, and no bound when it is not — see IdentityBoundOf. Identity *OfferIdentity `json:"identity,omitempty"` + // KeepsConsumerData says whether this provision's provider keeps what its consumers write — a + // database's rows, a bucket's objects, a client's settings — so that a consumer bound to it is + // bound to its data, and moves only by a person (novox/hq ADR 0232). `false` for a provision any + // provider answers alike, the mesh's resolver; unsaid, see KeepsConsumerData. + KeepsConsumerData *bool `json:"keeps-consumer-data,omitempty"` } // OfferIdentity is what an offer says about the names its backend keeps for its consumers. @@ -234,6 +239,40 @@ func (m Manifest) IdentityBoundOf(provision string) IdentityBound { return IdentityBound{} } +// KeepsConsumerData is whether this module, providing a provision, keeps what each consumer writes +// there (novox/hq ADR 0232): whether a consumer bound to it is bound to its data. +// +// **What an offer says, it gets.** Unsaid, it follows from whether the provider grants each consumer +// a credential of its own: a provider that does makes an account for every consumer — a role and its +// database, a key and its bucket, a client — and what the consumer writes under that account stays +// with that provider. One that grants nothing keeps nothing of anybody's: the resolver, a CA, the +// artifact store each answer any consumer alike, and moving a consumer between two of them loses +// nothing. +func (m Manifest) KeepsConsumerData(provision string) bool { + for _, o := range m.Provides { + if o.Name == provision && o.KeepsConsumerData != nil { + return *o.KeepsConsumerData + } + } + _, grants := m.Grants[provision] + return grants +} + +// KeepsConsumerData is whether a provision, by name, keeps its consumers' data across the +// catalogue: true when any module providing it at the mesh's scope does. **A property of the name**, +// as brokering is: two providers disagreeing would make the same binding sticky or free depending on +// which one happened to answer it, and the safe reading of a disagreement is that it keeps data. +func KeepsConsumerData(catalogue map[string]Manifest, provision string) bool { + for _, m := range catalogue { + for _, o := range m.Provides { + if o.Name == provision && o.At() == ScopeMesh && m.KeepsConsumerData(provision) { + return true + } + } + } + return false +} + // MachineReach is whether a provision is usable only on its provider's own machine. func (o Offer) MachineReach() bool { return o.Reach == ReachMachine } @@ -286,20 +325,23 @@ func (o *Offer) UnmarshalJSON(raw []byte) error { Credential *OfferCredential `json:"credential,omitempty"` Reach string `json:"reach,omitempty"` Identity *OfferIdentity `json:"identity,omitempty"` + Keeps *bool `json:"keeps-consumer-data,omitempty"` } dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() if err := dec.Decode(&full); err != nil { - return fmt.Errorf("a provided name is either a string or {name, scope, credential, reach, identity}: %w", err) + return fmt.Errorf("a provided name is either a string or {name, scope, credential, reach, identity, "+ + "keeps-consumer-data}: %w", err) } o.Name, o.Scope, o.Credential, o.Reach, o.Identity = full.Name, full.Scope, full.Credential, full.Reach, full.Identity + o.KeepsConsumerData = full.Keeps return nil } // MarshalJSON writes back the short form when there is nothing else to say, so a manifest that // went through the mesh comes out looking like the one that went in. func (o Offer) MarshalJSON() ([]byte, error) { - if o.Scope == "" && o.Credential == nil && o.Reach == "" && o.Identity == nil { + if o.Scope == "" && o.Credential == nil && o.Reach == "" && o.Identity == nil && o.KeepsConsumerData == nil { return json.Marshal(o.Name) } return json.Marshal(struct { @@ -308,7 +350,8 @@ func (o Offer) MarshalJSON() ([]byte, error) { Credential *OfferCredential `json:"credential,omitempty"` Reach string `json:"reach,omitempty"` Identity *OfferIdentity `json:"identity,omitempty"` - }{o.Name, o.Scope, o.Credential, o.Reach, o.Identity}) + Keeps *bool `json:"keeps-consumer-data,omitempty"` + }{o.Name, o.Scope, o.Credential, o.Reach, o.Identity, o.KeepsConsumerData}) } // Manifest is everything a module says about itself. diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index 55935f9..8547968 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -54,6 +54,11 @@ type World struct { // provider appears, and one recorded and then made unnecessary should not quietly stop applying // either. Pinned map[string]Chosen + // Bound is where each of this machine's consumers was bound for a provision that keeps its data + // (novox/hq ADR 0232), by consumer module then provision: the provider it was last sent, as the + // store recorded it. A resolution that would answer such a consumer from anywhere else keeps it + // where it is and says so (Resolution.Kept); only a pin naming the other provider moves it. + Bound map[string]map[string]Chosen // Licences is every provision answered by a **record rather than a node**, by provision name. // // novox/hq ADR 0024: a hosted model is on nobody's machine and is reached over the public @@ -151,6 +156,10 @@ type Resolution struct { // 0207) — reported rather than refused while enforceSeatDependencies is off, so a node short of a // holder still converges and `status` says what it is short of. Unheld []Unheld + // Kept is every consumer this resolution would have moved to another provider of a provision + // that keeps its data, and did not (novox/hq ADR 0232): it stays bound where its data is, and the + // controller raises an urgent condition naming the move until a person pins one or the other. + Kept []KeptBinding } // Unhostable is one directly-assigned module the machine cannot run. @@ -194,6 +203,13 @@ type Needed struct { // state, not a consumer missing its key. Set by the plan, which is the only layer that knows a // licence's manager; empty for every consumer. Manager bool + // Module is the providing module on From, empty for a need answered by a record or by nothing + // that serves it. A provider is a (node, module) pair (novox/hq to-be 23), and a binding to data + // is a binding to the pair (ADR 0232). + Module string + // KeepsData is set when the provision keeps what its consumer writes (novox/hq ADR 0232): the + // binding is to the consumer's data, recorded when it is sent and moved only by a pin. + KeepsData bool // Identity is the longest consumer identity the answering provision keeps (novox/hq ADR 0225), // from the provider's own offer: what the mesh judges this consumer's identity against, on the // consumer's side for `status` and on the provider's before it grants. No bound for a provision @@ -229,10 +245,15 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world // one happened to answer it. brokered := map[string]bool{} local := map[string]bool{} + // And which of them keep their consumers' data (novox/hq ADR 0232) — also a property of the name. + keeps := map[string]bool{} for _, m := range catalogue { for _, o := range m.Provides { if o.At() == ScopeMesh { brokered[o.Name] = true + if m.KeepsConsumerData(o.Name) { + keeps[o.Name] = true + } continue } local[o.Name] = true @@ -346,7 +367,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world // trust boundary the moment both ends are containers**, and treating it as one gave the // commonest arrangement of all — a service and its database on one node — the weakest // handling, silently. - if satisfied[want] && !isModule(catalogue, want) && !answeredElsewhere(want, node, world, brokered) { + if satisfied[want] && !isModule(catalogue, want) && !answeredElsewhere(want, node, world, brokered, keeps) { here := func(name string) bool { return chosen[name] || assignedHere[name] } local := providersHere(catalogue, here, want) // Which of them it matters to choose between. A plain capability — a shell, a display @@ -406,7 +427,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world at = "127.0.0.1" } needs = append(needs, Needed{ - Name: want, From: node.Name, At: at, + Name: want, From: node.Name, At: at, Module: by.Module, Serves: servedByOne(by, want), For: because[want], SharedOwn: sharedByOne(by, want), Identity: by.IdentityBoundOf(want)}) } else if served := servedByOne(by, want); len(served) > 0 { @@ -428,7 +449,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world at = "127.0.0.1" } needs = append(needs, Needed{ - Name: want, From: node.Name, At: at, Serves: served, For: because[want]}) + Name: want, From: node.Name, At: at, Serves: served, For: because[want], Module: by.Module}) } continue } @@ -459,7 +480,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world shared, _ = pm.SharedCredentialOf(want) bound = pm.IdentityBoundOf(want) } - needs = append(needs, Needed{Name: want, From: p.Node, At: p.At, + needs = append(needs, Needed{Name: want, From: p.Node, At: p.At, Module: p.Module, Serves: p.Serves, For: because[want], SharedOwn: shared, Identity: bound}) } switch { @@ -619,6 +640,17 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world // The record pass below already gets this right and says so. It is the same rule. needs = perConsumer(needs, order, catalogue) + // **A consumer bound to its data stays bound to it** (novox/hq ADR 0232). Per consumer, after + // the fan-out, because a binding is a consumer's: the walk above chose one provider per name for + // the whole machine, and two consumers of it may have been bound at different times. + var kept []KeptBinding + if !world.Unchecked { + here := func(name string) bool { return chosen[name] || assignedHere[name] } + var stuck []string + needs, kept, stuck = keepBound(needs, catalogue, node, world, keeps, here) + problems = append(problems, stuck...) + } + // What is answered by a record rather than by a machine. // // A post-pass, deliberately: nothing about it depends on the order requirements were walked @@ -668,7 +700,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world resolution := Resolution{Node: node.Name, At: node.At, PublicDomain: node.PublicDomain, Account: node.Account, AccountHome: node.AccountHome, - Because: because, Needs: needs, Unhostable: unhostable} + Because: because, Needs: needs, Unhostable: unhostable, Kept: kept} for _, n := range providersFirst(order, catalogue) { resolution.Modules = append(resolution.Modules, catalogue[n]) } @@ -1217,13 +1249,23 @@ func machineReachRemedy(catalogue map[string]Manifest, want, node string) string // overrule a pin somebody set on this machine. // // Not in the first pass, which asks only what this machine offers and has no providers to read. -func answeredElsewhere(want string, node Node, world World, brokered map[string]bool) bool { +// +// **And never by the seat alone for a provision that keeps its consumers' data** (novox/hq ADR +// 0232). The rule above was written for the mesh's resolver, which any provider answers alike. Read +// for the store's seat, it re-bound every database consumer on a machine running its own store to +// the seat's holder on another machine, which made each of them a fresh, empty database there and +// left their data behind (novox/hq issue 273). A provider beside a consumer of its data is where that +// data is; only a pin — a person — answers it from elsewhere. +func answeredElsewhere(want string, node Node, world World, brokered, keeps map[string]bool) bool { if world.Unchecked || !brokered[want] { return false } if c, pinned := world.Pinned[want]; pinned { return c.Node != node.Name } + if keeps[want] { + return false + } // **A machine holding the seat answers itself** (novox/hq ADR 0223). With a replicated seat // another machine holds it too, and the first holder in the providers' order may be that one; a // holder is still where this machine's own requirement is answered, so its resolver file lists diff --git a/internal/inventory/bindings.go b/internal/inventory/bindings.go new file mode 100644 index 0000000..75507ea --- /dev/null +++ b/internal/inventory/bindings.go @@ -0,0 +1,115 @@ +package inventory + +import ( + "context" + "time" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// A binding to data (novox/hq ADR 0232): where each consumer of a provision that keeps its data was +// last sent, so a resolution that would answer it from anywhere else keeps it there instead. + +// Binding is one consumer's recorded provider for one provision. +type Binding struct { + // Machine is where the consumer runs, Consumer the module there. + Machine string + Consumer string + Provision string + Provider catalogue.Chosen + BoundAt time.Time + SentAt time.Time + // MovedFrom is where it was bound before a pin moved it, empty when never moved. + MovedFrom string +} + +// BindingsFor is every binding recorded for a machine's consumers, by consumer then provision — the +// shape the resolver reads (catalogue.World.Bound). +func (i *Inventory) BindingsFor(ctx context.Context, machine string) (map[string]map[string]catalogue.Chosen, error) { + node, err := i.NodeByName(ctx, machine) + if err != nil { + return nil, err + } + rows, err := i.store.Pool().Query(ctx, + `select consumer, provision, provider_node, provider_module from binding where node = $1`, node.ID) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]map[string]catalogue.Chosen{} + for rows.Next() { + var consumer, provision string + var c catalogue.Chosen + if err := rows.Scan(&consumer, &provision, &c.Node, &c.Module); err != nil { + return nil, err + } + if out[consumer] == nil { + out[consumer] = map[string]catalogue.Chosen{} + } + out[consumer][provision] = c + } + return out, rows.Err() +} + +// Bindings is every binding recorded, by machine, consumer and provision. +func (i *Inventory) Bindings(ctx context.Context) ([]Binding, error) { + rows, err := i.store.Pool().Query(ctx, + `select n.name, b.consumer, b.provision, b.provider_node, b.provider_module, b.bound_at, b.sent_at, + coalesce(b.moved_from, '') + from binding b join node n on n.id = b.node + order by n.name, b.consumer, b.provision`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Binding + for rows.Next() { + var b Binding + if err := rows.Scan(&b.Machine, &b.Consumer, &b.Provision, &b.Provider.Node, &b.Provider.Module, + &b.BoundAt, &b.SentAt, &b.MovedFrom); err != nil { + return nil, err + } + out = append(out, b) + } + return out, rows.Err() +} + +// RecordBindings writes down the bindings a declaration just sent to a machine carried. A binding +// already recorded at the same provider is only marked sent; one recorded elsewhere — moved by a pin, +// the only way the resolver lets one move — keeps where it was in moved_from and is bound anew. +func (i *Inventory) RecordBindings(ctx context.Context, machine string, bindings []Binding) error { + if len(bindings) == 0 { + return nil + } + node, err := i.NodeByName(ctx, machine) + if err != nil { + return err + } + tx, err := i.store.Pool().Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + for _, b := range bindings { + _, err := tx.Exec(ctx, + `insert into binding (node, consumer, provision, provider_node, provider_module) + values ($1, $2, $3, $4, $5) + on conflict (node, consumer, provision) do update set + sent_at = now(), + moved_from = case + when binding.provider_node = excluded.provider_node + and binding.provider_module = excluded.provider_module then binding.moved_from + else binding.provider_node || '/' || binding.provider_module end, + bound_at = case + when binding.provider_node = excluded.provider_node + and binding.provider_module = excluded.provider_module then binding.bound_at + else now() end, + provider_node = excluded.provider_node, + provider_module = excluded.provider_module`, + node.ID, b.Consumer, b.Provision, b.Provider.Node, b.Provider.Module) + if err != nil { + return err + } + } + return tx.Commit(ctx) +} diff --git a/internal/inventory/bindings_test.go b/internal/inventory/bindings_test.go new file mode 100644 index 0000000..4767cbf --- /dev/null +++ b/internal/inventory/bindings_test.go @@ -0,0 +1,74 @@ +package inventory + +import ( + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// Where a consumer of data was bound is kept, and a move keeps where it was (novox/hq ADR 0232). +func TestABindingIsRecordedAndAMoveKeepsWhereItWas(t *testing.T) { + inv := fresh(t) + ctx := t.Context() + for _, n := range []string{"home", "anchor"} { + if _, err := inv.AddNode(ctx, n); err != nil { + t.Fatal(err) + } + } + home := catalogue.Chosen{Node: "home", Module: "store"} + anchor := catalogue.Chosen{Node: "anchor", Module: "store"} + if err := inv.RecordBindings(ctx, "home", []Binding{ + {Consumer: "board", Provision: "postgres-database", Provider: home}, + {Consumer: "game", Provision: "postgres-database", Provider: home}, + }); err != nil { + t.Fatal(err) + } + got, err := inv.BindingsFor(ctx, "home") + if err != nil { + t.Fatal(err) + } + if got["board"]["postgres-database"] != home || got["game"]["postgres-database"] != home { + t.Fatalf("recorded %+v", got) + } + first, err := inv.Bindings(ctx) + if err != nil || len(first) != 2 { + t.Fatalf("%+v, %v", first, err) + } + + // Sent again where it is: only marked sent. + if err := inv.RecordBindings(ctx, "home", []Binding{{Consumer: "board", Provision: "postgres-database", Provider: home}}); err != nil { + t.Fatal(err) + } + // Moved by a pin and sent: bound anew, with where it was. + if err := inv.RecordBindings(ctx, "home", []Binding{{Consumer: "game", Provision: "postgres-database", Provider: anchor}}); err != nil { + t.Fatal(err) + } + all, err := inv.Bindings(ctx) + if err != nil { + t.Fatal(err) + } + for _, b := range all { + switch b.Consumer { + case "board": + if b.Provider != home || b.MovedFrom != "" || !b.BoundAt.Equal(first[0].BoundAt) || b.Machine != "home" { + t.Errorf("a binding sent again where it is changed: %+v (was %+v)", b, first[0]) + } + case "game": + if b.Provider != anchor || b.MovedFrom != "home/store" || !b.BoundAt.After(first[1].BoundAt) { + t.Errorf("a moved binding: %+v", b) + } + } + } + + // A provider machine leaving keeps the record of where the data is. + if _, err := inv.store.Pool().Exec(ctx, `delete from node where name = 'anchor'`); err != nil { + t.Fatal(err) + } + got, err = inv.BindingsFor(ctx, "home") + if err != nil { + t.Fatal(err) + } + if got["game"]["postgres-database"] != anchor { + t.Fatalf("the record went with the provider's machine: %+v", got) + } +} diff --git a/internal/inventory/migrations/0071-a-binding-to-data-is-kept.sql b/internal/inventory/migrations/0071-a-binding-to-data-is-kept.sql new file mode 100644 index 0000000..848c839 --- /dev/null +++ b/internal/inventory/migrations/0071-a-binding-to-data-is-kept.sql @@ -0,0 +1,31 @@ +-- Where each consumer of a provision that keeps its data was bound (novox/hq ADR 0232, issue 273). +-- +-- A consumer of a database is bound to its rows. What resolving chooses — the seat's holder, a pin, +-- the providers assigned where — can change under a consumer without anybody meaning to move it, and +-- on 2026-10-05 one change to how a seat's holder answers re-bound five database consumers on one +-- machine to the store on another: each was made a fresh, empty database there, and nothing warned +-- for twenty hours. The pair secrets were the only trace, and only because a new pair was minted. +-- +-- One row per consumer and provision, written when a declaration carrying the binding is sent. A +-- resolution that would answer the consumer from another provider keeps this one and raises an urgent +-- condition; only a pin naming the other provider moves it, and the send that carries the move +-- rewrites the row, keeping where it was in `moved_from`. +-- +-- **The provider by name, not by reference.** A provider machine leaving the mesh must not take the +-- record of where a consumer's data is with it: the data is still there, and a cascade would turn +-- the record into nothing, which resolves as a binding never made — the silent move again. +-- +-- Numbered 0071, past 0070, the highest on main or any open branch when this was written. +create table binding ( + node uuid not null references node(id) on delete cascade, + consumer text not null, + provision text not null, + provider_node text not null, + provider_module text not null, + -- When it was first bound where it is, and when a declaration last carried it. + bound_at timestamptz not null default now(), + sent_at timestamptz not null default now(), + -- Where it was before a pin moved it, as `/`; null for a binding never moved. + moved_from text, + primary key (node, consumer, provision) +);