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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
Reference in New Issue
Block a user