Merge pull request 'Keep a consumer bound where its data is; only a pin moves it (hq ADR 0232, issue 273)' (#86) from fix/a-stateful-binding-moves-only-by-a-person into main

This commit was merged in pull request #86.
This commit is contained in:
2026-10-06 13:22:42 +00:00
14 changed files with 1072 additions and 9 deletions
+140
View File
@@ -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
}
+183
View File
@@ -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)
}
}
+3
View File
@@ -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},
}
+1
View File
@@ -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)
+31
View File
@@ -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
+13
View File
@@ -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))
+3
View File
@@ -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
+127
View File
@@ -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, ""
}
+257
View File
@@ -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)
}
}
+46 -3
View File
@@ -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.
+48 -6
View File
@@ -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
+115
View File
@@ -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)
}
+74
View File
@@ -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)
}
}
@@ -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 `<node>/<module>`; null for a binding never moved.
moved_from text,
primary key (node, consumer, provision)
);