Merge b7f/registry (the assigner's placement-check package) into b7/registry

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
2026-11-05 15:35:26 +01:00
24 changed files with 1845 additions and 422 deletions
@@ -23,6 +23,8 @@ import (
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
mmac "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient"
mma "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/buscompose"
@@ -40,6 +42,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/views"
"mesh-delivery-assigner/internal/assign"
plc "mesh-delivery-assigner/internal/placement"
)
// bed is an embedded bus with JetStream, every bucket the assigner reads and keeps, and the stream the mesh's events
@@ -87,7 +90,7 @@ func startBed(t *testing.T, leaveOut string) *bed {
continue
}
cfg := jetstream.KeyValueConfig{Bucket: name, History: 5}
if name == delivery.PlacementPulsesBucket {
if name == plc.PulsesBucket {
cfg.History, cfg.LimitMarkerTTL = 1, time.Minute // a time to live per key, as the controller makes it (key-ttl)
}
kv, err := js.CreateKeyValue(ctx, cfg)
@@ -263,19 +266,19 @@ func TestTheAssignerDoesNotStartWithoutABucketItReads(t *testing.T) {
// Without the seat's placement state it starts all the same, and once the bucket is made (the controller makes
// it from the manifest) it is read with no restart: a node it names gets its own want, the node composing its own.
pb := startBed(t, delivery.PlacementBucket)
pb := startBed(t, plc.PlacementBucket)
pdone := pb.start(t)
select {
case err := <-pdone:
t.Fatalf("without %s the assigner stopped: %v", delivery.PlacementBucket, err)
t.Fatalf("without %s the assigner stopped: %v", plc.PlacementBucket, err)
case <-time.After(2 * time.Second):
}
placement, err := pb.js.CreateKeyValue(pb.ctx, jetstream.KeyValueConfig{Bucket: delivery.PlacementBucket, History: 5})
placement, err := pb.js.CreateKeyValue(pb.ctx, jetstream.KeyValueConfig{Bucket: plc.PlacementBucket, History: 5})
if err != nil {
t.Fatal(err)
}
pl, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Modules: []delivery.PlacedModule{{Module: "web"}}})
plkey, _ := delivery.LegacyPlacementKey("n1") // the form before, read beside the new (research 072 row 37)
pl, _ := mmac.EncodePlacement(mma.Placement{Node: "n1", Modules: []mma.PlacedModule{{Module: "web"}}})
plkey, _ := mmac.LegacyKey("n1") // the form before, read beside the new (research 072 row 37)
if _, err := placement.Put(pb.ctx, plkey, pl); err != nil {
t.Fatal(err)
}
@@ -302,16 +305,16 @@ func (b *bed) place(t *testing.T, node, module string) {
if !slices.Contains(b.placed[node], module) {
b.placed[node] = append(b.placed[node], module)
}
pl := delivery.Placement{Node: node}
pl := mma.Placement{Node: node}
for _, m := range b.placed[node] {
pl.Modules = append(pl.Modules, delivery.PlacedModule{Module: m})
pl.Modules = append(pl.Modules, mma.PlacedModule{Module: m})
}
values, err := delivery.PlacementValues(pl)
values, err := mmac.PlacementValues(pl)
if err != nil {
t.Fatal(err)
}
for key, raw := range values {
b.putJSON(t, delivery.PlacementBucket, key, raw)
b.putJSON(t, plc.PlacementBucket, key, raw)
}
}
@@ -479,12 +482,12 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
// assignments name it on. Each node then composes its own: its want names the build alone (Self, no composition), and
// its own want says it composes its own, which the node agent reads (mesh-host's TestNats...ComposesAndAppliesItsOwn).
for _, node := range []string{"ace", "g14", "shanks"} {
pl, err := delivery.EncodePlacement(delivery.Placement{Node: node, Modules: []delivery.PlacedModule{{Module: "web"}}})
pl, err := mmac.EncodePlacement(mma.Placement{Node: node, Modules: []mma.PlacedModule{{Module: "web"}}})
if err != nil {
t.Fatal(err)
}
pkey, _ := delivery.LegacyPlacementKey(node)
b.putJSON(t, delivery.PlacementBucket, pkey, pl)
pkey, _ := mmac.LegacyKey(node)
b.putJSON(t, plc.PlacementBucket, pkey, pl)
}
if w := b.wantNow(t, "ace", "web", "ace's want once placed: the build alone, composed by the node", func(w delivery.Want) bool { return w.Self }); w.Composed != "" || w.FP != fNew {
t.Errorf("ace's want once placed = %+v", w)
@@ -1117,14 +1120,14 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) {
Subjects: placementSubjects()}); err != nil { // the seat's four events: the views family has a stream of its own
t.Fatal(err)
}
heard := make(chan delivery.PlacementEvent, 16)
heard := make(chan mma.PlacementEvent, 16)
nc, err := nats.Connect(b.srv.ClientURL())
if err != nil {
t.Fatal(err)
}
t.Cleanup(nc.Close)
if _, err := nc.Subscribe(process.ModuleStreamSubject(delivery.AssignerModule, delivery.PlacementChangedEvent), func(m *nats.Msg) {
var e delivery.PlacementEvent
if _, err := nc.Subscribe(process.ModuleStreamSubject(delivery.AssignerModule, mma.EventPlacementChanged), func(m *nats.Msg) {
var e mma.PlacementEvent
if json.Unmarshal(m.Data, &e) == nil {
heard <- e
}
@@ -1140,25 +1143,25 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) {
// place writes a node's placement as another writer would, in its node and module keys (research 072 row 37), and
// answers its node key's revision; legacy writes it in the form before, placement.<node>, alone.
place := func(node string, legacy bool, modules ...string) uint64 {
p := delivery.Placement{Node: node, Modules: []delivery.PlacedModule{}}
p := mma.Placement{Node: node, Modules: []mma.PlacedModule{}}
for _, m := range modules {
p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
p.Modules = append(p.Modules, mma.PlacedModule{Module: m})
}
values, _ := delivery.PlacementValues(p)
key, _ := delivery.PlacementNodeKey(node)
values, _ := mmac.PlacementValues(p)
key, _ := mmac.NodeKey(node)
if legacy {
key, _ = delivery.LegacyPlacementKey(node)
key, _ = mmac.LegacyKey(node)
values = map[string][]byte{key: values[key]}
}
for _, k := range slices.Sorted(maps.Keys(values)) {
if k == key {
continue
}
if _, err := b.buckets[delivery.PlacementBucket].Put(b.ctx, k, values[k]); err != nil {
if _, err := b.buckets[plc.PlacementBucket].Put(b.ctx, k, values[k]); err != nil {
t.Fatal(err)
}
}
rev, err := b.buckets[delivery.PlacementBucket].Put(b.ctx, key, values[key])
rev, err := b.buckets[plc.PlacementBucket].Put(b.ctx, key, values[key])
if err != nil {
t.Fatal(err)
}
@@ -1173,9 +1176,9 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) {
// The holder converts anchor, held in the form before alone, to its node and module keys once at its start.
var anchorRev uint64
for deadline := time.Now().Add(15 * time.Second); anchorRev == 0; time.Sleep(50 * time.Millisecond) {
if e, err := b.buckets[delivery.PlacementBucket].Get(b.ctx, "anchor.node"); err == nil {
if e, err := b.buckets[plc.PlacementBucket].Get(b.ctx, "anchor.node"); err == nil {
anchorRev = e.Revision()
if _, err := b.buckets[delivery.PlacementBucket].Get(b.ctx, "anchor.web"); err != nil {
if _, err := b.buckets[plc.PlacementBucket].Get(b.ctx, "anchor.web"); err != nil {
t.Fatalf("anchor converted without its module key: %v", err)
}
}
@@ -1184,7 +1187,7 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) {
}
}
verbs := newSeatTools(bedStore{b}, func(string, any) (json.RawMessage, error) { return json.RawMessage(`{"ok":true}`), nil })
propose := func() delivery.PlacementProposal {
propose := func() mma.Proposal {
t.Helper()
got, err := verbs.doPropose(map[string]any{"why": "move web", "changes": `[{"op":"take-off","node":"anchor","module":"web"},` +
`{"op":"place","node":"laptop","module":"web"}]`}, "operator@laptop")
@@ -1193,12 +1196,12 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) {
}
return got.(proposed).Proposal
}
proposalNow := func(id, what string, ok func(delivery.PlacementProposal) bool) delivery.PlacementProposal {
proposalNow := func(id, what string, ok func(mma.Proposal) bool) mma.Proposal {
t.Helper()
var last delivery.PlacementProposal
var last mma.Proposal
for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if e, err := b.buckets[delivery.PlacementProposalsBucket].Get(b.ctx, id); err == nil {
if p, err := delivery.DecodePlacementProposal(e.Value()); err == nil {
if e, err := b.buckets[plc.ProposalsBucket].Get(b.ctx, id); err == nil {
if p, err := plc.DecodeProposal(e.Value()); err == nil {
if last = p; ok(p) {
return p
}
@@ -1208,11 +1211,11 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) {
t.Fatalf("the proposal %s never was %s: %+v", id, what, last)
return last
}
answerAll := func(p delivery.PlacementProposal) {
answerAll := func(p mma.Proposal) {
t.Helper()
for _, node := range p.Must {
key, _ := delivery.PlacementCandidateKey(p.ID, node)
e, err := b.buckets[delivery.PlacementCandidatesBucket].Get(b.ctx, key)
key, _ := plc.CandidateKey(p.ID, node)
e, err := b.buckets[plc.CandidatesBucket].Get(b.ctx, key)
if err != nil {
t.Fatalf("no candidate %s: %v", key, err)
}
@@ -1223,18 +1226,18 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) {
}
p := propose()
p = proposalNow(p.ID, "told who must answer", func(p delivery.PlacementProposal) bool { return len(p.Must) > 0 })
p = proposalNow(p.ID, "told who must answer", func(p mma.Proposal) bool { return len(p.Must) > 0 })
if !slices.Equal(p.Must, []string{"anchor", "laptop"}) {
t.Fatalf("must answer = %v, want both nodes", p.Must)
}
answerAll(p)
p = proposalNow(p.ID, "asked", func(p delivery.PlacementProposal) bool { return p.State == delivery.ProposalAsked })
p = proposalNow(p.ID, "asked", func(p mma.Proposal) bool { return p.State == plc.ProposalAsked })
akey, _ := delivery.PlacementAskKey(p.ID)
b.answer(t, akey, assign.OptionYes)
select {
case e := <-heard:
if e.Proposal.ID != p.ID || len(e.Nodes) != 2 || e.Nodes[0].Before != anchorRev || e.Nodes[1].Before != laptopRev ||
e.Nodes[0].After <= anchorRev || e.Warrant == nil || !slices.Equal(e.Nodes[0].Removed, []string{"web"}) {
if e.Proposal.ID != p.ID || len(e.Nodes) != 2 || changeIn(e.Nodes[0]).Before != anchorRev || changeIn(e.Nodes[1]).Before != laptopRev ||
changeIn(e.Nodes[0]).After <= anchorRev || e.Warrant == nil || !slices.Equal(changeIn(e.Nodes[0]).Removed, []string{"web"}) {
t.Errorf("placement-changed = %+v", e)
}
case <-time.After(15 * time.Second):
@@ -1244,20 +1247,20 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) {
// The node key names the modules, each module key carries its own, and the mirror of the form before follows.
values := map[string][]byte{}
for _, key := range []string{node + ".node", "placement." + node, node + ".web", node + ".shell"} {
if e, err := b.buckets[delivery.PlacementBucket].Get(b.ctx, key); err == nil {
if e, err := b.buckets[plc.PlacementBucket].Get(b.ctx, key); err == nil {
values[key] = e.Value()
}
}
got, err := delivery.PlacementsOf(values)
if err != nil || !slices.Equal(got[node].Assigned(), want) {
t.Errorf("%s = %v, %v, want %v", node, got[node].Assigned(), err, want)
got, err := mmac.PlacementsOf(values)
if err != nil || !slices.Equal(mmac.Assigned(got[node]), want) {
t.Errorf("%s = %v, %v, want %v", node, mmac.Assigned(got[node]), err, want)
}
mirror, _ := delivery.DecodePlacement(values["placement."+node])
if !slices.Equal(mirror.Assigned(), want) {
t.Errorf("the mirror placement.%s = %v, want %v", node, mirror.Assigned(), want)
mirror, _ := mmac.DecodePlacement(values["placement."+node])
if !slices.Equal(mmac.Assigned(mirror), want) {
t.Errorf("the mirror placement.%s = %v, want %v", node, mmac.Assigned(mirror), want)
}
}
if b.hasKey(delivery.PlacementProposalsBucket, p.ID) {
if b.hasKey(plc.ProposalsBucket, p.ID) {
t.Error("an applied proposal stays in the state")
}
@@ -1268,20 +1271,20 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) {
}
moved := q.(proposed).Proposal
place("laptop", false, "shell", "web") // another writer moved the key
relaid := proposalNow(moved.ID, "laid again", func(p delivery.PlacementProposal) bool {
relaid := proposalNow(moved.ID, "laid again", func(p mma.Proposal) bool {
return strings.Contains(p.Words, "laid again") && len(p.Must) > 0
})
answerAll(relaid)
proposalNow(moved.ID, "asked", func(p delivery.PlacementProposal) bool { return p.State == delivery.ProposalAsked })
proposalNow(moved.ID, "asked", func(p mma.Proposal) bool { return p.State == plc.ProposalAsked })
akey, _ = delivery.PlacementAskKey(moved.ID)
b.answer(t, akey, assign.OptionNo)
for deadline := time.Now().Add(15 * time.Second); b.hasKey(delivery.PlacementProposalsBucket, moved.ID); time.Sleep(50 * time.Millisecond) {
for deadline := time.Now().Add(15 * time.Second); b.hasKey(plc.ProposalsBucket, moved.ID); time.Sleep(50 * time.Millisecond) {
if time.Now().After(deadline) {
t.Fatal("a declined proposal was not ended")
}
}
key, _ := delivery.PlacementNodeKey("laptop")
if e, _ := b.buckets[delivery.PlacementBucket].Get(b.ctx, key); e == nil || !strings.Contains(string(e.Value()), "web") {
key, _ := mmac.NodeKey("laptop")
if e, _ := b.buckets[plc.PlacementBucket].Get(b.ctx, key); e == nil || !strings.Contains(string(e.Value()), "web") {
t.Error("a declined proposal wrote its key")
}
@@ -1292,7 +1295,7 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) {
t.Fatal(err)
}
silent := q.(proposed).Proposal
decided := proposalNow(silent.ID, "decided without its silent nodes", func(p delivery.PlacementProposal) bool { return p.State == delivery.ProposalAsked })
decided := proposalNow(silent.ID, "decided without its silent nodes", func(p mma.Proposal) bool { return p.State == plc.ProposalAsked })
if decided.Verdict == nil || decided.Verdict.State != delivery.VerdictPass ||
!strings.Contains(strings.Join(decided.Verdict.Notes, "; "), "no answer from") || !strings.Contains(strings.Join(decided.Verdict.Notes, "; "), "laptop (offline)") {
t.Errorf("the silent node's verdict = %+v", decided.Verdict)
@@ -1311,16 +1314,16 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) {
}
select {
case e := <-heard:
if e.Proposal.ID != joined.(proposed).Proposal.ID || e.Warrant == nil || e.Warrant.Warrant != "enrol-ace-1@2026-10-28T20:00:00Z" || len(e.Nodes) != 1 || e.Nodes[0].Node != "ace" {
if e.Proposal.ID != joined.(proposed).Proposal.ID || e.Warrant == nil || e.Warrant.Warrant != "enrol-ace-1@2026-10-28T20:00:00Z" || len(e.Nodes) != 1 || changeIn(e.Nodes[0]).Node != "ace" {
t.Errorf("placement-changed for the joining node = %+v", e)
}
case <-time.After(15 * time.Second):
t.Fatal("no placement-changed heard for the joining node")
}
if ace, err := b.buckets[delivery.PlacementBucket].Get(b.ctx, "ace.node"); err != nil || !strings.Contains(string(ace.Value()), "10.9.0.2") {
if ace, err := b.buckets[plc.PlacementBucket].Get(b.ctx, "ace.node"); err != nil || !strings.Contains(string(ace.Value()), "10.9.0.2") {
t.Errorf("the joining node's key = %v, %v", ace, err)
}
if akey, _ := delivery.PlacementAskKey(joined.(proposed).Proposal.ID); b.hasKey(delivery.AssignerAsksBucket, akey) || b.hasKey(delivery.PlacementProposalsBucket, joined.(proposed).Proposal.ID) {
if akey, _ := delivery.PlacementAskKey(joined.(proposed).Proposal.ID); b.hasKey(delivery.AssignerAsksBucket, akey) || b.hasKey(plc.ProposalsBucket, joined.(proposed).Proposal.ID) {
t.Error("an ask was raised for the joining node, or its proposal stays open")
}
// Its enrolment ends before it confirms: a remove-node on the same warrant deletes its key at once, with no ask, and
@@ -1334,13 +1337,13 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) {
}
select {
case e := <-heard:
if e.Warrant == nil || len(e.Nodes) != 1 || e.Nodes[0].Node != "ace" {
if e.Warrant == nil || len(e.Nodes) != 1 || changeIn(e.Nodes[0]).Node != "ace" {
t.Errorf("placement-changed for the removed node = %+v", e)
}
case <-time.After(15 * time.Second):
t.Fatal("no placement-changed heard for the removed node")
}
if _, err := b.buckets[delivery.PlacementBucket].Get(b.ctx, "ace.node"); err == nil {
if _, err := b.buckets[plc.PlacementBucket].Get(b.ctx, "ace.node"); err == nil {
t.Error("the removed node's key stays")
}
}
@@ -1423,7 +1426,7 @@ func (s bedSource) Get(key string) (*views.Entry, bool, error) {
// placementSubjects are the subjects of the seat's four events on the assigner's stream placement.
func placementSubjects() []string {
var out []string
for _, e := range delivery.PlacementEvents {
for _, e := range mma.Events() {
out = append(out, process.ModuleStreamSubject(delivery.AssignerModule, e))
}
return out
@@ -1605,3 +1608,11 @@ func firstViewsBeforeAnyNewFormState(t *testing.T, b *bed, granted map[string][]
}
}
}
// changeIn reads one entry of a placement-changed event's nodes.
func changeIn(body map[string]any) plc.Change {
raw, _ := json.Marshal(body)
var c plc.Change
_ = json.Unmarshal(raw, &c)
return c
}
@@ -23,6 +23,7 @@ import (
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
mma "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
@@ -33,6 +34,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/views"
"mesh-delivery-assigner/internal/assign"
plc "mesh-delivery-assigner/internal/placement"
)
func main() {
@@ -73,8 +75,8 @@ var readFamilies = []string{delivery.ComposedRead}
// ownBuckets are the assigner's own states, as its manifest declares them: the seat mesh-placement's three among them
// (ADR 0567 §1), named after the assigner as every module state is.
var ownBuckets = []string{delivery.AssignerAsksBucket, delivery.AssignerDifferencesBucket,
delivery.AssignerPutsBucket, delivery.AssignerWantsBucket, delivery.PlacementBucket, delivery.PlacementProposalsBucket,
delivery.PlacementCandidatesBucket, delivery.PlacementPulsesBucket, assign.FindingsBucket, assign.PoliciesBucket, delivery.SeatHoldersBucket,
delivery.AssignerPutsBucket, delivery.AssignerWantsBucket, plc.PlacementBucket, plc.ProposalsBucket,
plc.CandidatesBucket, plc.PulsesBucket, assign.FindingsBucket, assign.PoliciesBucket, plc.SeatHoldersBucket,
engineReadsBucket}
// engineReadsState is the assigner's state of what each node's engine states it reads (ADR 0618 §3), one key `<node>`, as
@@ -86,8 +88,8 @@ const engineReadsBucket = delivery.AssignerModule + "_" + engineReadsState
// seatBuckets are the seat's states, new with it: until the controller has made them from the manifest and the bus grants
// them, the assigner starts without them and binds each once the bus says it was created (issue 1790's kind).
var seatBuckets = []string{delivery.PlacementBucket, delivery.PlacementProposalsBucket, delivery.PlacementCandidatesBucket,
delivery.PlacementPulsesBucket}
var seatBuckets = []string{plc.PlacementBucket, plc.ProposalsBucket, plc.CandidatesBucket,
plc.PulsesBucket}
// keptStates is what the assigner keeps and who may read it (ADR 0495 §2-§3): its wants by the watchman and the view
// (and the node agent from release 6), its differences by the view, its asks by the view
@@ -95,17 +97,17 @@ var seatBuckets = []string{delivery.PlacementBucket, delivery.PlacementProposals
// candidates by the node agents, the grants part, mesh-config and the view (ADR 0567 §9), and its placement by
// the builder, the checker and the indexer, through mesh-sdk's toolchain gate (issue 774, ADR 0549 §3).
var keptStates = []announce.State{
{Name: delivery.PlacementState, Readable: true},
{Name: delivery.PlacementProposalsState, Readable: true},
{Name: delivery.PlacementCandidatesState, Readable: true},
{Name: delivery.PlacementPulsesState, Readable: true},
{Name: mma.StatePlacement, Readable: true},
{Name: mma.StateProposals, Readable: true},
{Name: mma.StateCandidates, Readable: true},
{Name: mma.StatePulses, Readable: true},
{Name: "asks", Readable: true},
{Name: "differences", Readable: true},
{Name: "puts", Readable: true},
{Name: "wants", Readable: true},
{Name: health.FindingsState, Readable: true},
{Name: delivery.AssignerPoliciesState, Readable: true},
{Name: delivery.SeatHoldersState, Readable: true},
{Name: mma.StateSeatHolders, Readable: true},
{Name: views.MarksState, Readable: true}, // what its views family said each state up to (views.go)
{Name: views.StatusState, Readable: true}, // its first views of the indexer's states (views.go)
{Name: engineReadsState, Readable: true},
@@ -131,7 +133,7 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any
logf("the bucket %s cannot be opened (%v): it is bound once the bus says it was made", name, err)
continue
}
if name == delivery.SeatHoldersBucket && err != nil {
if name == plc.SeatHoldersBucket && err != nil {
// The seat's state, new with ADR 0571 §4: bound once the bus says it was made; until then nobody is told who
// holds which seat here, and the controller's seats still answer.
logf("the bucket %s cannot be opened (%v): it is bound once the bus says it was made", name, err)
@@ -184,7 +186,7 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any
// states and those new with a delivery of their own, bound once made, and the two that choose the grants' source.
func startsWithout(name string) bool {
switch name {
case delivery.SeatHoldersBucket, provisions.ProvisionsBucket, assign.PoliciesBucket, assign.FindingsBucket,
case plc.SeatHoldersBucket, provisions.ProvisionsBucket, assign.PoliciesBucket, assign.FindingsBucket,
delivery.AssignerPutsBucket, delivery.SourceBucket, delivery.MergesBucket, engineReadsBucket:
return true
}
@@ -269,10 +271,10 @@ var watched = []string{
delivery.PartGrantsBucket,
delivery.AnswersBucket,
delivery.BuilderBuildsBucket,
delivery.PlacementBucket,
delivery.PlacementProposalsBucket,
delivery.PlacementCandidatesBucket,
delivery.PlacementPulsesBucket,
plc.PlacementBucket,
plc.ProposalsBucket,
plc.CandidatesBucket,
plc.PulsesBucket,
delivery.PresenceBucket,
delivery.HaveBucket,
delivery.AssignerAsksBucket,
@@ -280,7 +282,7 @@ var watched = []string{
delivery.AssignerWantsBucket,
assign.PoliciesBucket,
provisions.ProvisionsBucket,
delivery.SeatHoldersBucket,
plc.SeatHoldersBucket,
delivery.SourceBucket,
delivery.MergesBucket,
engineReadsBucket,
@@ -425,7 +427,7 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
}
}
// And the two that choose where grants are read from, with the same delivery as the grants part that writes one.
for _, name := range []string{delivery.PartGrantsBucket, assign.PoliciesBucket, provisions.ProvisionsBucket, delivery.SeatHoldersBucket} {
for _, name := range []string{delivery.PartGrantsBucket, assign.PoliciesBucket, provisions.ProvisionsBucket, plc.SeatHoldersBucket} {
if kvs[name] == nil && js != nil {
name := name
process.AwaitBucket(ctx, js, nc, name, "until then the bucket is not read",
@@ -2,6 +2,7 @@ package main
import (
"mesh-delivery-assigner/internal/assign"
plc "mesh-delivery-assigner/internal/placement"
"context"
"errors"
@@ -19,6 +20,8 @@ import (
"github.com/nats-io/nats.go/jetstream"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
mmac "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient"
mma "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/process"
@@ -109,12 +112,12 @@ func TestOpensWhatItsManifestNames(t *testing.T) {
for _, name := range append(append([]string{}, readBuckets...), ownBuckets...) {
kvs[name] = &watchKV{memKV: &memKV{values: map[string][]byte{}}}
}
placed, err := delivery.PlacementValues(delivery.Placement{Node: "ace"})
placed, err := mmac.PlacementValues(mma.Placement{Node: "ace"})
if err != nil {
t.Fatal(err)
}
for key, raw := range placed {
kv := kvs[delivery.PlacementBucket].(*watchKV)
kv := kvs[plc.PlacementBucket].(*watchKV)
kv.entries = append(kv.entries, kvEntry{key: key, value: raw})
}
kvs[delivery.AssignerPutsBucket].(*watchKV).closed = true
@@ -18,6 +18,7 @@ import (
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
"mesh-delivery-assigner/internal/assign"
plc "mesh-delivery-assigner/internal/placement"
)
// passer makes the assigner's passes: from what the world holds now, it writes each key whose value differs from the
@@ -1011,7 +1012,7 @@ func missingPulse(key string) string { return "provision-missing." + key }
// `provider.<node>.<module>.<provision>.provision-missing` is written; one keyed again ends both. The pulse's expiry, heard on
// the watch, is the pass that writes it: no timer.
func (p *passer) provisionsMissing(ctx context.Context, missing map[string]bool) {
pulses := p.kvs[delivery.PlacementPulsesBucket]
pulses := p.kvs[plc.PulsesBucket]
if p.missing == nil {
p.missing = map[string]bool{}
}
@@ -1047,7 +1048,7 @@ func (p *passer) provisionsMissing(ctx context.Context, missing map[string]bool)
delete(p.missing, key)
if pulses != nil && p.pulsing(missingPulse(key)) {
if err := pulses.Delete(ctx, missingPulse(key)); err == nil {
p.w.put(delivery.PlacementPulsesBucket, missingPulse(key), nil)
p.w.put(plc.PulsesBucket, missingPulse(key), nil)
}
}
if p.missingSaid[key] && p.findings.Delete != nil {
@@ -1102,7 +1103,7 @@ func (p *passer) unplacedFindings() {
// placement holds any more deleted. A pass follows each placement change and each manifest the indexer takes in, so no
// timer writes it.
func (p *passer) seatHolders(ctx context.Context) {
kv := p.kvs[delivery.SeatHoldersBucket]
kv := p.kvs[plc.SeatHoldersBucket]
if kv == nil {
return
}
@@ -1110,14 +1111,14 @@ func (p *passer) seatHolders(ctx context.Context) {
if len(placements) == 0 {
return // no placement read yet: nothing is known of who holds what, and nothing is deleted
}
want := delivery.SeatHoldersOf(placements)
have := p.w.read(delivery.SeatHoldersBucket)
want := plc.SeatHoldersOf(placements)
have := p.w.read(plc.SeatHoldersBucket)
for key, sh := range want {
value, err := json.Marshal(sh)
if err != nil || string(have[key]) == string(value) {
continue
}
p.put(ctx, delivery.SeatHoldersBucket, key, func() ([]byte, error) { return value, nil })
p.put(ctx, plc.SeatHoldersBucket, key, func() ([]byte, error) { return value, nil })
}
for key := range have {
if _, ok := want[key]; ok {
@@ -1127,7 +1128,7 @@ func (p *passer) seatHolders(ctx context.Context) {
p.logf("the seat holders %s are not deleted: %v", key, err)
continue
}
p.w.put(delivery.SeatHoldersBucket, key, nil)
p.w.put(plc.SeatHoldersBucket, key, nil)
}
}
@@ -15,6 +15,8 @@ import (
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
mmac "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient"
mma "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-sdk/go/busretry"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
@@ -24,6 +26,7 @@ import (
"git.novox.be/novox/mesh-delivery/sdk/graph"
"mesh-delivery-assigner/internal/assign"
plc "mesh-delivery-assigner/internal/placement"
)
// memKV is the part of a bucket a pass writes: puts and deletes.
@@ -59,16 +62,16 @@ func newPasser(kvs buckets, at time.Time) *passer {
// (research 072 row 37).
func placeOn(t *testing.T, w *world, node string, modules ...string) {
t.Helper()
pl := delivery.Placement{Node: node}
pl := mma.Placement{Node: node}
for _, m := range modules {
pl.Modules = append(pl.Modules, delivery.PlacedModule{Module: m})
pl.Modules = append(pl.Modules, mma.PlacedModule{Module: m})
}
values, err := delivery.PlacementValues(pl)
values, err := mmac.PlacementValues(pl)
if err != nil {
t.Fatal(err)
}
for key, raw := range values {
w.put(delivery.PlacementBucket, key, raw)
w.put(plc.PlacementBucket, key, raw)
}
}
@@ -985,8 +988,8 @@ func aPlacedNodesWantsNameTheBuildAloneAndItsYesComesFromItsHave(t *testing.T) {
mod, _ := delivery.EncodeModule(delivery.Module{FP2: fpTest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: delivery.RootPath})
put(delivery.ModulesBucket, "web", mod) // the trunk's build is not done: rule 1 holds g14 at fpOld
placeOn(t, p.w, "g14", "web")
pl, _ := delivery.EncodePlacement(delivery.Placement{Node: "g14", Modules: []delivery.PlacedModule{{Module: "web"}}})
put(delivery.PlacementBucket, "placement.g14", pl)
pl, _ := mmac.EncodePlacement(mma.Placement{Node: "g14", Modules: []mma.PlacedModule{{Module: "web"}}})
put(plc.PlacementBucket, "placement.g14", pl)
on, _ := delivery.EncodePresence(delivery.Presence{Online: true})
put(delivery.PresenceBucket, "g14", on)
have, _ := delivery.EncodeHave(delivery.Have{FP: fpOld, Composed: was, Phase: delivery.PhaseRunning, Health: delivery.HealthOK})
@@ -1070,10 +1073,10 @@ func TestAnApprovedPlacementCallsNoControllerVerb(t *testing.T) {
p.seat = newSeatActs(bus, func() {}, func(string, ...any) {})
var said []string
p.publish = func(event string, _ any, _ string) error { said = append(said, event); return nil }
prop := delivery.PlacementProposal{ID: "p-one", By: "operator@laptop", Why: "move web"}
change := delivery.ChangeOf(delivery.Placement{Node: "anchor"}, delivery.Placement{Node: "anchor", Modules: []delivery.PlacedModule{{Module: "web"}}})
p.followWrite(prop, delivery.Answer{}, []delivery.PlacementChange{change})
if len(bus.calls) != 0 || !slices.Equal(said, []string{delivery.PlacementChangedEvent}) {
prop := mma.Proposal{ID: "p-one", By: "operator@laptop", Why: "move web"}
change := plc.ChangeOf(mma.Placement{Node: "anchor"}, mma.Placement{Node: "anchor", Modules: []mma.PlacedModule{{Module: "web"}}})
p.followWrite(prop, delivery.Answer{}, []plc.Change{change})
if len(bus.calls) != 0 || !slices.Equal(said, []string{mma.EventPlacementChanged}) {
t.Fatalf("calls = %d, said = %v", len(bus.calls), said)
}
@@ -17,11 +17,14 @@ import (
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
mmac "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient"
mma "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/process"
"mesh-delivery-assigner/internal/assign"
"mesh-delivery-assigner/internal/judge"
plc "mesh-delivery-assigner/internal/placement"
)
// The seat mesh-placement's holder in the assigner's process (novox/hq ADR 0567 §2-§8): each pass follows every open
@@ -136,24 +139,24 @@ func (p *passer) sayWaits(unbound []string) {
// seatPlacements is every node's placement and the revision of its node key (its legacy key's for a node not yet
// converted), as the watch holds them: the revision a proposal is laid over (§2, research 072 row 37).
func (p *passer) seatPlacements() (map[string]delivery.Placement, map[string]uint64) {
func (p *passer) seatPlacements() (map[string]mma.Placement, map[string]uint64) {
return p.w.placement.AllRevisions()
}
// candidatesOf is a proposal's candidates by node, with their keys' revisions and the newest write.
func (p *passer) candidatesOf(id string) (map[string]delivery.PlacementCandidate, map[string]uint64, time.Time) {
out, revs := map[string]delivery.PlacementCandidate{}, map[string]uint64{}
func (p *passer) candidatesOf(id string) (map[string]mma.CandidatesByProposalNodeValue, map[string]uint64, time.Time) {
out, revs := map[string]mma.CandidatesByProposalNodeValue{}, map[string]uint64{}
var newest time.Time
for key, value := range p.w.read(delivery.PlacementCandidatesBucket) {
pid, node, err := delivery.ParsePlacementCandidateKey(key)
for key, value := range p.w.read(plc.CandidatesBucket) {
pid, node, err := plc.ParseCandidateKey(key)
if err != nil || pid != id {
continue
}
c, err := delivery.DecodePlacementCandidate(value)
c, err := plc.DecodeCandidate(value)
if err != nil {
continue
}
meta := p.w.metaOf(delivery.PlacementCandidatesBucket, key)
meta := p.w.metaOf(plc.CandidatesBucket, key)
out[node], revs[node] = c, meta.revision
if meta.at.After(newest) {
newest = meta.at
@@ -194,50 +197,50 @@ func (p *passer) placementSeat(ctx context.Context) {
p.convertPlacement(ctx)
current, revs := p.seatPlacements()
ids := make([]string, 0)
raws := p.w.read(delivery.PlacementProposalsBucket)
raws := p.w.read(plc.ProposalsBucket)
for id := range raws {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
prop, err := delivery.DecodePlacementProposal(raws[id])
prop, err := plc.DecodeProposal(raws[id])
if err != nil || prop.ID != id {
p.logf("the proposal %s cannot be read, so it is left: %v", id, err)
continue
}
p.stepProposal(ctx, prop, p.w.metaOf(delivery.PlacementProposalsBucket, id).revision, current, revs)
p.stepProposal(ctx, prop, p.w.metaOf(plc.ProposalsBucket, id).revision, current, revs)
}
}
// pulse writes a bound's key with its time to live (§4): its expiry, heard on the watch as the server's delete marker,
// is the event that ends the wait. Written again over one that stands, so its time starts anew.
func (p *passer) pulse(ctx context.Context, key string, ttl time.Duration) {
kv := p.kvs[delivery.PlacementPulsesBucket]
kv := p.kvs[plc.PulsesBucket]
_ = kv.Delete(ctx, key)
rev, err := kv.Create(ctx, key, []byte(`{}`), jetstream.KeyTTL(ttl))
if err != nil {
p.logf("the bound %s is not written, so its wait ends at once: %v", key, err)
return
}
p.w.putEntry(delivery.PlacementPulsesBucket, key, []byte(`{}`), rev, p.clock())
p.w.putEntry(plc.PulsesBucket, key, []byte(`{}`), rev, p.clock())
}
// pulsing says whether a bound's key stands.
func (p *passer) pulsing(key string) bool { return p.w.has(delivery.PlacementPulsesBucket, key) }
func (p *passer) pulsing(key string) bool { return p.w.has(plc.PulsesBucket, key) }
// deletePulses deletes every bound of a proposal.
func (p *passer) deletePulses(ctx context.Context, id string) {
for key := range p.w.read(delivery.PlacementPulsesBucket) {
for key := range p.w.read(plc.PulsesBucket) {
if strings.HasSuffix(key, "."+id) || strings.Contains(key, "."+id+".") {
if err := p.kvs[delivery.PlacementPulsesBucket].Delete(ctx, key); err == nil {
p.w.put(delivery.PlacementPulsesBucket, key, nil)
if err := p.kvs[plc.PulsesBucket].Delete(ctx, key); err == nil {
p.w.put(plc.PulsesBucket, key, nil)
}
}
}
}
// ask raises (or raises again) a proposal's authorising ask and its bound, and answers the proposal asked.
func (p *passer) ask(ctx context.Context, prop delivery.PlacementProposal, akey, brief string) (delivery.PlacementProposal, bool) {
func (p *passer) ask(ctx context.Context, prop mma.Proposal, akey, brief string) (mma.Proposal, bool) {
a, err := assign.PlacementAsk(akey, prop, brief, assign.PlacementAsking(prop, p.clock()), p.clock())
if err != nil {
p.logf("the ask about the proposal %s is not written: %v", prop.ID, err)
@@ -249,17 +252,17 @@ func (p *passer) ask(ctx context.Context, prop delivery.PlacementProposal, akey,
return prop, false
}
p.put(ctx, delivery.AssignerAsksBucket, akey, func() ([]byte, error) { return assign.EncodeAsk(akey, a) })
if pk, err := delivery.PulseAskKey(prop.ID); err == nil {
if pk, err := plc.PulseAskKey(prop.ID); err == nil {
p.pulse(ctx, pk, assign.ExpiresAt(placed).Sub(p.clock()))
}
prop.State, prop.Ask = delivery.ProposalAsked, placed.ID
prop.State, prop.Ask = plc.ProposalAsked, placed.ID
return prop, true
}
// stepProposal takes one proposal one step further.
func (p *passer) stepProposal(ctx context.Context, prop delivery.PlacementProposal, rev uint64, current map[string]delivery.Placement, revs map[string]uint64) {
if !p.saidProposed[prop.ID] && prop.State == delivery.ProposalAnswering && prop.Verdict == nil {
p.sayEvent(delivery.PlacementProposedEvent, delivery.PlacementEvent{Proposal: prop}, prop.ID)
func (p *passer) stepProposal(ctx context.Context, prop mma.Proposal, rev uint64, current map[string]mma.Placement, revs map[string]uint64) {
if !p.saidProposed[prop.ID] && prop.State == plc.ProposalAnswering && prop.Verdict == nil {
p.sayEvent(mma.EventPlacementProposed, mma.PlacementEvent{Proposal: prop}, prop.ID)
p.saidProposed[prop.ID] = true
}
if prop.Withdrawn != "" {
@@ -276,9 +279,9 @@ func (p *passer) stepProposal(ctx context.Context, prop delivery.PlacementPropos
return
}
akey, _ := delivery.PlacementAskKey(prop.ID)
again := !prop.Again.IsZero() && (prop.Verdict == nil || prop.Again.After(prop.Verdict.At))
if prop.State == delivery.ProposalAsked {
askPulse, _ := delivery.PulseAskKey(prop.ID)
again := prop.Again != nil && !prop.Again.IsZero() && (prop.Verdict == nil || prop.Again.After(prop.Verdict.At))
if prop.State == plc.ProposalAsked {
askPulse, _ := plc.PulseAskKey(prop.ID)
switch {
case p.answeredWith(akey, assign.OptionYes):
p.warranted(ctx, prop, rev, akey, current, revs)
@@ -305,12 +308,12 @@ func (p *passer) stepProposal(ctx context.Context, prop delivery.PlacementPropos
return // a candidate just written is not heard yet: its watch wakes the next pass
}
}
if written.After(prop.Written) {
prop.Written = written
if !written.IsZero() && (prop.Written == nil || written.After(*prop.Written)) {
prop.Written = &written
}
pulsing := map[string]bool{}
for _, node := range prop.Must {
if k, err := delivery.PulseAnswerKey(prop.ID, node); err == nil {
if k, err := plc.PulseAnswerKey(prop.ID, node); err == nil {
pulsing[node] = p.pulsing(k)
}
}
@@ -321,11 +324,11 @@ func (p *passer) stepProposal(ctx context.Context, prop delivery.PlacementPropos
prop.Verdict = &v
switch v.State {
case delivery.VerdictFail:
prop.State, prop.Ask = delivery.ProposalFailed, ""
case delivery.VerdictWaits:
prop.State, prop.Ask = delivery.ProposalAnswering, ""
prop.State, prop.Ask = plc.ProposalFailed, ""
case plc.VerdictWaits:
prop.State, prop.Ask = plc.ProposalAnswering, ""
case delivery.VerdictPass:
if prop.State != delivery.ProposalAsked || again {
if prop.State != plc.ProposalAsked || again {
var ok bool
if prop, ok = p.ask(ctx, prop, akey, judge.Brief(prop, v)); !ok {
return
@@ -336,21 +339,21 @@ func (p *passer) stepProposal(ctx context.Context, prop delivery.PlacementPropos
}
}
if p.writeProposal(ctx, prop, rev) {
p.sayEvent(delivery.PlacementJudgedEvent, delivery.PlacementEvent{Proposal: prop},
p.sayEvent(mma.EventPlacementJudged, mma.PlacementEvent{Proposal: prop},
fmt.Sprintf("%s.judged.%d", prop.ID, v.At.UnixNano()))
}
}
// sameVerdict says two verdicts say the same, their time aside.
func sameVerdict(a, b delivery.PlacementVerdict) bool {
func sameVerdict(a, b mma.Verdict) bool {
a.At, b.At = time.Time{}, time.Time{}
return reflect.DeepEqual(a, b)
}
// moved says whether a key the proposal changes is no longer at the revision it was laid over.
func moved(prop delivery.PlacementProposal, revs map[string]uint64) bool {
func moved(prop mma.Proposal, revs map[string]uint64) bool {
for node, base := range prop.Base {
if revs[node] != base {
if revs[node] != uint64(base) {
return true
}
}
@@ -358,7 +361,7 @@ func moved(prop delivery.PlacementProposal, revs map[string]uint64) bool {
}
// intendedOf is, by proposal and the revisions it was laid over, what Intended computed, kept while this process runs.
func (p *passer) intendedOf(prop delivery.PlacementProposal, current map[string]delivery.Placement, cands map[string]delivery.PlacementCandidate) map[string]map[string]bool {
func (p *passer) intendedOf(prop mma.Proposal, current map[string]mma.Placement, cands map[string]mma.CandidatesByProposalNodeValue) map[string]map[string]bool {
key := fmt.Sprint(prop.ID, prop.Base)
if got, ok := p.intended[key]; ok {
return got
@@ -371,9 +374,9 @@ func (p *passer) intendedOf(prop delivery.PlacementProposal, current map[string]
// compute is what the proposal changes on each node by its placement, and the nodes that answer it: every node with a
// placement or a record (judge.Intended, ADR 0593 §8). The holder composes nothing: each node says what its compositions
// change in its answer.
func (p *passer) compute(prop delivery.PlacementProposal, current map[string]delivery.Placement, cands map[string]delivery.PlacementCandidate) (map[string]map[string]bool, []string) {
laid := map[string]*delivery.Placement{}
for _, node := range prop.Nodes() {
func (p *passer) compute(prop mma.Proposal, current map[string]mma.Placement, cands map[string]mma.CandidatesByProposalNodeValue) (map[string]map[string]bool, []string) {
laid := map[string]*mma.Placement{}
for _, node := range plc.Nodes(prop) {
if c, ok := cands[node]; ok && !c.Reached {
laid[node] = c.Placement
}
@@ -390,8 +393,8 @@ func (p *passer) compute(prop delivery.PlacementProposal, current map[string]del
// mustAnswer finds the nodes the proposal must hear from (§3), writes a reached candidate for each whose key it does not
// change, and writes them into the proposal.
func (p *passer) mustAnswer(ctx context.Context, prop delivery.PlacementProposal, rev uint64, current map[string]delivery.Placement, cands map[string]delivery.PlacementCandidate) {
for _, node := range prop.Nodes() {
func (p *passer) mustAnswer(ctx context.Context, prop mma.Proposal, rev uint64, current map[string]mma.Placement, cands map[string]mma.CandidatesByProposalNodeValue) {
for _, node := range plc.Nodes(prop) {
if _, ok := cands[node]; !ok {
return // the verbs write the candidates before the proposal: wait to hear them
}
@@ -402,18 +405,18 @@ func (p *passer) mustAnswer(ctx context.Context, prop delivery.PlacementProposal
if _, ok := cands[node]; ok {
continue
}
key, err := delivery.PlacementCandidateKey(prop.ID, node)
key, err := plc.CandidateKey(prop.ID, node)
if err != nil {
continue
}
c := delivery.PlacementCandidate{Proposal: prop.ID, Reached: true}
c := mma.CandidatesByProposalNodeValue{Proposal: prop.ID, Reached: true}
if pl, ok := current[node]; ok {
c.Placement = &pl
}
p.put(ctx, delivery.PlacementCandidatesBucket, key, func() ([]byte, error) { return delivery.EncodePlacementCandidate(c) })
p.put(ctx, plc.CandidatesBucket, key, func() ([]byte, error) { return plc.EncodeCandidate(c) })
}
for _, node := range must {
if k, err := delivery.PulseAnswerKey(prop.ID, node); err == nil {
if k, err := plc.PulseAnswerKey(prop.ID, node); err == nil {
p.pulse(ctx, k, judge.AnswerBound)
}
}
@@ -423,7 +426,7 @@ func (p *passer) mustAnswer(ctx context.Context, prop delivery.PlacementProposal
// layAgain lays a proposal over the keys as they stand now (§5: a key moved): its candidates are written again, the
// nodes that must answer found again, and it is judged and asked again; a proposal that no longer lays ends.
func (p *passer) layAgain(ctx context.Context, prop delivery.PlacementProposal, rev uint64, current map[string]delivery.Placement, revs map[string]uint64, why string) {
func (p *passer) layAgain(ctx context.Context, prop mma.Proposal, rev uint64, current map[string]mma.Placement, revs map[string]uint64, why string) {
held, recorded := p.lays(ctx)
again, laid, findings := assign.Propose(assign.ProposeIn{ID: prop.ID, By: prop.By, Why: prop.Why, At: prop.At, Changes: prop.Changes,
Current: current, Revisions: revs, Held: held, Recorded: recorded})
@@ -433,12 +436,12 @@ func (p *passer) layAgain(ctx context.Context, prop delivery.PlacementProposal,
}
p.deleteCandidates(ctx, prop.ID)
p.deletePulses(ctx, prop.ID)
for _, node := range again.Nodes() {
key, _ := delivery.PlacementCandidateKey(prop.ID, node)
c := delivery.PlacementCandidate{Proposal: prop.ID, Placement: laid[node]}
p.put(ctx, delivery.PlacementCandidatesBucket, key, func() ([]byte, error) { return delivery.EncodePlacementCandidate(c) })
for _, node := range plc.Nodes(again) {
key, _ := plc.CandidateKey(prop.ID, node)
c := mma.CandidatesByProposalNodeValue{Proposal: prop.ID, Placement: laid[node]}
p.put(ctx, plc.CandidatesBucket, key, func() ([]byte, error) { return plc.EncodeCandidate(c) })
}
again.Written, again.Words = p.clock().UTC(), why+": laid again over the keys as they stand, to be judged and asked again"
again.Written, again.Words = ptr(p.clock().UTC()), why+": laid again over the keys as they stand, to be judged and asked again"
p.writeProposal(ctx, again, rev)
}
@@ -457,7 +460,7 @@ func (p *passer) lays(ctx context.Context) (func(string) bool, func(string) bool
// warranted writes an approved proposal (§5): each changed key by compare-and-set at the revision it was laid over; then the warrant recorded, the candidates and the
// proposal deleted, and placement-changed said.
func (p *passer) warranted(ctx context.Context, prop delivery.PlacementProposal, rev uint64, akey string, current map[string]delivery.Placement, revs map[string]uint64) {
func (p *passer) warranted(ctx context.Context, prop mma.Proposal, rev uint64, akey string, current map[string]mma.Placement, revs map[string]uint64) {
if err := p.performs(akey, assign.PlacementAct(prop)); err != nil {
p.endProposal(ctx, prop, "its warrant does not bind these changes, so nothing is written: "+err.Error())
return
@@ -468,8 +471,8 @@ func (p *passer) warranted(ctx context.Context, prop delivery.PlacementProposal,
return
}
cands, _, _ := p.candidatesOf(prop.ID)
laid := map[string]*delivery.Placement{}
for _, node := range prop.Nodes() {
laid := map[string]*mma.Placement{}
for _, node := range plc.Nodes(prop) {
laid[node] = cands[node].Placement
}
writes, err := assign.Writes(prop, laid)
@@ -477,8 +480,8 @@ func (p *passer) warranted(ctx context.Context, prop delivery.PlacementProposal,
p.logf("the proposal %s cannot be written: %v", prop.ID, err)
return
}
var changes []delivery.PlacementChange
writtenLaid := map[string]*delivery.Placement{}
var changes []plc.Change
writtenLaid := map[string]*mma.Placement{}
after := maps.Clone(current)
afterRevs := maps.Clone(revs)
for _, w := range writes {
@@ -496,7 +499,7 @@ func (p *passer) warranted(ctx context.Context, prop delivery.PlacementProposal,
return
}
before := current[w.Node]
now := delivery.Placement{Node: w.Node}
now := mma.Placement{Node: w.Node}
if w.Value != nil {
now = *w.Value
after[w.Node] = *w.Value
@@ -504,15 +507,15 @@ func (p *passer) warranted(ctx context.Context, prop delivery.PlacementProposal,
delete(after, w.Node)
}
afterRevs[w.Node] = rev2
c := delivery.ChangeOf(before, now)
c := plc.ChangeOf(before, now)
c.Before, c.After = w.Base, rev2
changes = append(changes, c)
writtenLaid[w.Node] = w.Value
}
p.deleteCandidates(ctx, prop.ID)
p.deletePulses(ctx, prop.ID)
_ = p.kvs[delivery.PlacementProposalsBucket].Delete(ctx, prop.ID)
p.w.put(delivery.PlacementProposalsBucket, prop.ID, nil)
_ = p.kvs[plc.ProposalsBucket].Delete(ctx, prop.ID)
p.w.put(plc.ProposalsBucket, prop.ID, nil)
p.followWrite(prop, answer, changes)
}
@@ -520,16 +523,16 @@ func (p *passer) warranted(ctx context.Context, prop delivery.PlacementProposal,
// was the issue's, so no node is waited on and no ask is raised. The key is created at the revision it was laid over (none: a
// node with a key is no joining node); the enrolment recorded the warrant in the hand-act log when it was given, so this does
// not record it again; placement-changed carries the warrant. A key written meanwhile ends the proposal, saying so.
func (p *passer) addNode(ctx context.Context, prop delivery.PlacementProposal, revs map[string]uint64) {
func (p *passer) addNode(ctx context.Context, prop mma.Proposal, revs map[string]uint64) {
current, _ := p.seatPlacements()
if moved(prop, revs) {
p.endProposal(ctx, prop, "a key it was laid over was written since: the enrolment proposes the node again")
return
}
laid, findings := delivery.LayChanges(current, prop.Changes)
laid, findings := plc.LayChanges(current, prop.Changes)
findings = append(findings, assign.ControllerRefusals(prop.Changes, laid)...)
node := prop.Changes[0].Node
if prop.Changes[0].Op == delivery.OpRemoveNode {
if prop.Changes[0].Op == plc.OpRemoveNode {
p.removeNode(ctx, prop, current, findings)
return
}
@@ -543,22 +546,22 @@ func (p *passer) addNode(ctx context.Context, prop delivery.PlacementProposal, r
p.endProposal(ctx, prop, "the node's key was not written: "+err.Error())
return
}
c := delivery.ChangeOf(delivery.Placement{Node: node}, value)
c.Before, c.After = prop.Base[node], rev
c := plc.ChangeOf(mma.Placement{Node: node}, value)
c.Before, c.After = uint64(prop.Base[node]), rev
p.deleteCandidates(ctx, prop.ID)
p.deletePulses(ctx, prop.ID)
_ = p.kvs[delivery.PlacementProposalsBucket].Delete(ctx, prop.ID)
p.w.put(delivery.PlacementProposalsBucket, prop.ID, nil)
_ = p.kvs[plc.ProposalsBucket].Delete(ctx, prop.ID)
p.w.put(plc.ProposalsBucket, prop.ID, nil)
w := prop.Warrant
p.sayChanged(prop, delivery.Answer{Option: assign.OptionYes, Warrant: w.Warrant,
Words: fmt.Sprintf("the operator approved the machine %s at %s through the enrolment's ask %s", w.Node, w.Address, w.Ask)},
[]delivery.PlacementChange{c})
[]plc.Change{c})
}
// removeNode deletes the key of a joining node whose enrolment ended unconfirmed, on that enrolment's warrant (the operator's
// decision of 2026-11-02 01:05): no node answers and no ask is raised, as for its add-node; placement-changed carries the warrant.
// A node that runs modules or holds seats is no joining node and stays (LayChanges' finding ends the proposal).
func (p *passer) removeNode(ctx context.Context, prop delivery.PlacementProposal, current map[string]delivery.Placement, findings []string) {
func (p *passer) removeNode(ctx context.Context, prop mma.Proposal, current map[string]mma.Placement, findings []string) {
node := prop.Changes[0].Node
before, ok := current[node]
if len(findings) > 0 || !ok {
@@ -570,21 +573,21 @@ func (p *passer) removeNode(ctx context.Context, prop delivery.PlacementProposal
p.endProposal(ctx, prop, "the node's key was not deleted: "+err.Error())
return
}
c := delivery.ChangeOf(before, delivery.Placement{Node: node})
c.Before, c.After = prop.Base[node], rev
c := plc.ChangeOf(before, mma.Placement{Node: node})
c.Before, c.After = uint64(prop.Base[node]), rev
p.deleteCandidates(ctx, prop.ID)
p.deletePulses(ctx, prop.ID)
_ = p.kvs[delivery.PlacementProposalsBucket].Delete(ctx, prop.ID)
p.w.put(delivery.PlacementProposalsBucket, prop.ID, nil)
_ = p.kvs[plc.ProposalsBucket].Delete(ctx, prop.ID)
p.w.put(plc.ProposalsBucket, prop.ID, nil)
w := prop.Warrant
p.sayChanged(prop, delivery.Answer{Option: assign.OptionYes, Warrant: w.Warrant,
Words: fmt.Sprintf("the machine %s left placement: its enrolment (ask %s) ended before it confirmed", w.Node, w.Ask)},
[]delivery.PlacementChange{c})
[]plc.Change{c})
}
// followWrite follows the keys a warrant wrote: the warrant recorded and placement-changed said. No verb of the
// controller is called: the placement is the one source of where modules run (ADR 0567 §8, issue 774).
func (p *passer) followWrite(prop delivery.PlacementProposal, answer delivery.Answer, changes []delivery.PlacementChange) {
func (p *passer) followWrite(prop mma.Proposal, answer delivery.Answer, changes []plc.Change) {
if len(changes) == 0 {
return
}
@@ -592,7 +595,7 @@ func (p *passer) followWrite(prop delivery.PlacementProposal, answer delivery.An
p.sayChanged(prop, answer, changes)
}
func sortedNodes(m map[string]*delivery.Placement) []string {
func sortedNodes(m map[string]*mma.Placement) []string {
out := make([]string, 0, len(m))
for n := range m {
out = append(out, n)
@@ -602,7 +605,7 @@ func sortedNodes(m map[string]*delivery.Placement) []string {
}
// sayChanged says placement-changed for the keys written.
func (p *passer) sayChanged(prop delivery.PlacementProposal, answer delivery.Answer, changes []delivery.PlacementChange) {
func (p *passer) sayChanged(prop mma.Proposal, answer delivery.Answer, changes []plc.Change) {
if len(changes) == 0 {
return
}
@@ -612,14 +615,14 @@ func (p *passer) sayChanged(prop delivery.PlacementProposal, answer delivery.Ans
at = t
}
}
delivery.SortChanges(changes)
p.sayEvent(delivery.PlacementChangedEvent, delivery.PlacementEvent{Proposal: prop, Nodes: changes,
Warrant: &delivery.PlacementWarrant{Warrant: answer.Warrant, Words: answer.Words, At: at}}, prop.ID+".changed")
plc.SortChanges(changes)
p.sayEvent(mma.EventPlacementChanged, mma.PlacementEvent{Proposal: prop, Nodes: changeBodies(changes),
Warrant: &mma.PlacementEventWarrant{Warrant: answer.Warrant, Words: answer.Words, At: at}}, prop.ID+".changed")
}
// endProposal ends a proposal: its ask, its candidates and its key deleted, and placement-withdrawn said carrying it
// whole with why it ended.
func (p *passer) endProposal(ctx context.Context, prop delivery.PlacementProposal, why string) {
func (p *passer) endProposal(ctx context.Context, prop mma.Proposal, why string) {
if akey, err := delivery.PlacementAskKey(prop.ID); err == nil && p.w.has(delivery.AssignerAsksBucket, akey) {
if err := p.kvs[delivery.AssignerAsksBucket].Delete(ctx, akey); err == nil {
p.w.put(delivery.AssignerAsksBucket, akey, nil)
@@ -627,21 +630,21 @@ func (p *passer) endProposal(ctx context.Context, prop delivery.PlacementProposa
}
p.deleteCandidates(ctx, prop.ID)
p.deletePulses(ctx, prop.ID)
if err := p.kvs[delivery.PlacementProposalsBucket].Delete(ctx, prop.ID); err != nil && !errors.Is(err, context.Canceled) {
if err := p.kvs[plc.ProposalsBucket].Delete(ctx, prop.ID); err != nil && !errors.Is(err, context.Canceled) {
p.logf("the proposal %s is not deleted, and is ended at the next pass: %v", prop.ID, err)
return
}
p.w.put(delivery.PlacementProposalsBucket, prop.ID, nil)
p.w.put(plc.ProposalsBucket, prop.ID, nil)
p.logf("the proposal %s ends: %s", prop.ID, why)
p.sayEvent(delivery.PlacementWithdrawnEvent, delivery.PlacementEvent{Proposal: prop, Ended: why}, prop.ID+".withdrawn")
p.sayEvent(mma.EventPlacementWithdrawn, mma.PlacementEvent{Proposal: prop, Ended: why}, prop.ID+".withdrawn")
}
// deleteCandidates deletes every candidate of a proposal.
func (p *passer) deleteCandidates(ctx context.Context, id string) {
for key := range p.w.read(delivery.PlacementCandidatesBucket) {
if pid, _, err := delivery.ParsePlacementCandidateKey(key); err == nil && pid == id {
if err := p.kvs[delivery.PlacementCandidatesBucket].Delete(ctx, key); err == nil {
p.w.put(delivery.PlacementCandidatesBucket, key, nil)
for key := range p.w.read(plc.CandidatesBucket) {
if pid, _, err := plc.ParseCandidateKey(key); err == nil && pid == id {
if err := p.kvs[plc.CandidatesBucket].Delete(ctx, key); err == nil {
p.w.put(plc.CandidatesBucket, key, nil)
}
}
}
@@ -649,13 +652,13 @@ func (p *passer) deleteCandidates(ctx context.Context, id string) {
// writeProposal writes a proposal over the revision the pass read, by compare-and-set: a verb that marked it meanwhile
// (withdraw, again) wins, and the next pass reads that. It says whether it wrote.
func (p *passer) writeProposal(ctx context.Context, prop delivery.PlacementProposal, rev uint64) bool {
raw, err := delivery.EncodePlacementProposal(prop)
func (p *passer) writeProposal(ctx context.Context, prop mma.Proposal, rev uint64) bool {
raw, err := plc.EncodeProposal(prop)
if err != nil {
p.logf("the proposal %s is not written: %v", prop.ID, err)
return false
}
kv := p.kvs[delivery.PlacementProposalsBucket]
kv := p.kvs[plc.ProposalsBucket]
var at uint64
if rev == 0 {
at, err = kv.Create(ctx, prop.ID, raw)
@@ -668,12 +671,12 @@ func (p *passer) writeProposal(ctx context.Context, prop delivery.PlacementPropo
}
return false
}
p.w.putEntry(delivery.PlacementProposalsBucket, prop.ID, raw, at, p.clock())
p.w.putEntry(plc.ProposalsBucket, prop.ID, raw, at, p.clock())
return true
}
// say says one of the seat's events, said in the journal when the bus refuses it.
func (p *passer) sayEvent(event string, body delivery.PlacementEvent, id string) {
func (p *passer) sayEvent(event string, body mma.PlacementEvent, id string) {
if p.publish == nil {
return
}
@@ -682,19 +685,19 @@ func (p *passer) sayEvent(event string, body delivery.PlacementEvent, id string)
}
}
// writeNode writes one node's placement after (nil: none) as its keys (mesh-sdk delivery.PlacementKeys.Writes; research
// writeNode writes one node's placement after (nil: none) as its keys (mesh-sdk mmac.Keys.Writes; research
// 072 row 37): the module keys that change, each carrying the value it replaces, then the node key, the commit, then the
// module keys no longer placed. It answers the node key's revision after (0: deleted). A write that fails up to the
// commit ends it with nothing changed for any reader, the module keys written before it read at their previous values;
// a delete after the commit that fails leaves a module key the node key does not name, which places nothing and is
// deleted at the node's next write. Each key written is taken into the world at once, so the next write of the pass
// reads it.
func (p *passer) writeNode(ctx context.Context, node string, after *delivery.Placement) (uint64, error) {
func (p *passer) writeNode(ctx context.Context, node string, after *mma.Placement) (uint64, error) {
writes, err := p.w.placement.Writes(node, after)
if err != nil {
return 0, err
}
kv := p.kvs[delivery.PlacementBucket]
kv := p.kvs[plc.PlacementBucket]
committed, rev := false, uint64(0)
for _, w := range writes {
got, err := writeKey(ctx, kv, w)
@@ -705,7 +708,7 @@ func (p *passer) writeNode(ctx context.Context, node string, after *delivery.Pla
}
return 0, fmt.Errorf("placement %s at revision %d: %w", w.Key, w.Base, err)
}
p.w.putEntry(delivery.PlacementBucket, w.Key, w.Value, got, p.clock())
p.w.putEntry(plc.PlacementBucket, w.Key, w.Value, got, p.clock())
if w.Commit {
committed, rev = true, got
}
@@ -714,7 +717,7 @@ func (p *passer) writeNode(ctx context.Context, node string, after *delivery.Pla
}
// writeKey makes one write of a placement key by compare-and-set and answers the key's revision after (0: deleted).
func writeKey(ctx context.Context, kv jetstream.KeyValue, w delivery.PlacementKeyWrite) (uint64, error) {
func writeKey(ctx context.Context, kv jetstream.KeyValue, w mmac.KeyWrite) (uint64, error) {
switch {
case w.Value == nil:
return 0, kv.Delete(ctx, w.Key, jetstream.LastRevision(w.Base))
@@ -751,3 +754,14 @@ func (p *passer) convertPlacement(ctx context.Context) {
}
p.convertedAll = !failed && err == nil
}
func ptr[T any](v T) *T { return &v }
// changeBodies are the changes as the seat's event carries them.
func changeBodies(changes []plc.Change) []map[string]any {
out := make([]map[string]any, 0, len(changes))
for _, c := range changes {
out = append(out, c.Body())
}
return out
}
@@ -4,16 +4,18 @@ import (
"encoding/json"
"errors"
"fmt"
"git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
"slices"
"sort"
"strings"
"time"
mmac "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient"
mma "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"mesh-delivery-assigner/internal/assign"
plc "mesh-delivery-assigner/internal/placement"
)
// The seat mesh-placement's seven verbs (novox/hq ADR 0567 §10), served through the tool runner beside the put tools: a
@@ -55,25 +57,25 @@ func newSeatTools(st seatStore, ask func(string, any) (json.RawMessage, error))
// keyed is one placement key as a verb answers it.
type keyed struct {
Placement delivery.Placement `json:"placement"`
Revision uint64 `json:"revision"`
Placement mma.Placement `json:"placement"`
Revision uint64 `json:"revision"`
}
// placements reads every node's placement and the revision of its node key (its legacy key's before it is converted),
// assembled from its keys in either form (delivery.PlacementKeys; research 072 row 37).
func (s *seatTools) placements() (map[string]delivery.Placement, map[string]uint64, error) {
keys, err := s.st.keys(delivery.PlacementState)
// assembled from its keys in either form (mmac.Keys; research 072 row 37).
func (s *seatTools) placements() (map[string]mma.Placement, map[string]uint64, error) {
keys, err := s.st.keys(mma.StatePlacement)
if err != nil {
return nil, nil, fmt.Errorf("placement cannot be listed: %w", err)
}
sort.Strings(keys)
held := delivery.NewPlacementKeys()
held := mmac.NewKeys()
var bad []string
for _, key := range keys {
if _, _, _, err := delivery.ParsePlacementKey(key); err != nil {
if _, _, _, err := mmac.ParseKey(key); err != nil {
continue
}
raw, rev, found, err := s.st.get(delivery.PlacementState, key)
raw, rev, found, err := s.st.get(mma.StatePlacement, key)
if err != nil {
return nil, nil, fmt.Errorf("placement key %s cannot be read: %w", key, err)
}
@@ -123,42 +125,42 @@ func (s *seatTools) lays() (func(string) bool, func(string) bool, error) {
}
// proposal reads one open proposal.
func (s *seatTools) proposal(id string) (delivery.PlacementProposal, error) {
if err := delivery.CheckProposalID(id); err != nil {
return delivery.PlacementProposal{}, err
func (s *seatTools) proposal(id string) (mma.Proposal, error) {
if err := mmac.CheckProposalID(id); err != nil {
return mma.Proposal{}, err
}
raw, _, found, err := s.st.get(delivery.PlacementProposalsState, id)
raw, _, found, err := s.st.get(mma.StateProposals, id)
if err != nil {
return delivery.PlacementProposal{}, fmt.Errorf("the proposal %s cannot be read: %w", id, err)
return mma.Proposal{}, fmt.Errorf("the proposal %s cannot be read: %w", id, err)
}
if !found {
return delivery.PlacementProposal{}, fmt.Errorf("no proposal %s is open: mesh-placement.proposals lists those that are", id)
return mma.Proposal{}, fmt.Errorf("no proposal %s is open: mesh-placement.proposals lists those that are", id)
}
return delivery.DecodePlacementProposal(raw)
return plc.DecodeProposal(raw)
}
// write writes a proposal laid by assign.Propose: each candidate, then the proposal, whose write the assigner's process
// takes as the event to compose and judge it.
func (s *seatTools) write(p delivery.PlacementProposal, laid map[string]*delivery.Placement) error {
p.Written = s.now().UTC()
for _, node := range p.Nodes() {
key, err := delivery.PlacementCandidateKey(p.ID, node)
func (s *seatTools) write(p mma.Proposal, laid map[string]*mma.Placement) error {
p.Written = ptr(s.now().UTC())
for _, node := range plc.Nodes(p) {
key, err := plc.CandidateKey(p.ID, node)
if err != nil {
return err
}
raw, err := delivery.EncodePlacementCandidate(delivery.PlacementCandidate{Proposal: p.ID, Placement: laid[node]})
raw, err := plc.EncodeCandidate(mma.CandidatesByProposalNodeValue{Proposal: p.ID, Placement: laid[node]})
if err != nil {
return err
}
if err := s.st.put(delivery.PlacementCandidatesState, key, raw); err != nil {
if err := s.st.put(mma.StateCandidates, key, raw); err != nil {
return fmt.Errorf("the candidate %s is not written: %w", key, err)
}
}
raw, err := delivery.EncodePlacementProposal(p)
raw, err := plc.EncodeProposal(p)
if err != nil {
return err
}
if err := s.st.put(delivery.PlacementProposalsState, p.ID, raw); err != nil {
if err := s.st.put(mma.StateProposals, p.ID, raw); err != nil {
return fmt.Errorf("the proposal %s is not written: %w", p.ID, err)
}
return nil
@@ -166,8 +168,8 @@ func (s *seatTools) write(p delivery.PlacementProposal, laid map[string]*deliver
// proposed is what propose and adopt answer.
type proposed struct {
Proposal delivery.PlacementProposal `json:"proposal"`
Follow string `json:"follow"`
Proposal mma.Proposal `json:"proposal"`
Follow string `json:"follow"`
}
func followProposal(id string) string {
@@ -176,7 +178,7 @@ func followProposal(id string) string {
}
// changesOf reads propose's changes: a JSON array, given as one or as its text.
func changesOf(v any) ([]delivery.PlacementProposalChange, error) {
func changesOf(v any) ([]mma.ProposalChange, error) {
var raw []byte
switch c := v.(type) {
case string:
@@ -189,7 +191,7 @@ func changesOf(v any) ([]delivery.PlacementProposalChange, error) {
return nil, err
}
}
var out []delivery.PlacementProposalChange
var out []mma.ProposalChange
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
if err := dec.Decode(&out); err != nil {
@@ -205,7 +207,7 @@ func (s *seatTools) doPropose(args map[string]any, by string) (any, error) {
return nil, err
}
for _, c := range changes {
if c.Op == delivery.OpSeed {
if c.Op == plc.OpSeed {
return nil, errors.New("the seed is gone with adopt: propose takes place, take-off, pin, unpin, hold, unhold, decide and config")
}
}
@@ -222,9 +224,9 @@ func (s *seatTools) doPropose(args map[string]any, by string) (any, error) {
return nil, err
}
now := s.now()
p, laid, findings := assign.Propose(assign.ProposeIn{ID: delivery.NewProposalID(now), By: by, Why: arg(args, "why"), At: now,
p, laid, findings := assign.Propose(assign.ProposeIn{ID: plc.NewProposalID(now), By: by, Why: arg(args, "why"), At: now,
Changes: changes, Current: current, Revisions: revs, Held: held, Recorded: recorded, Warrant: warrant})
if refusal := delivery.AddNodeRefusal(p, moduleOfCaller(by)); refusal != "" {
if refusal := plc.AddNodeRefusal(p, moduleOfCaller(by), delivery.EnrolmentModule); refusal != "" {
findings = append(findings, refusal)
}
if len(findings) > 0 {
@@ -237,7 +239,7 @@ func (s *seatTools) doPropose(args map[string]any, by string) (any, error) {
}
// warrantOf reads propose's warrant, an enrolment's for an add-node: an object, given as one or as its text; none is none.
func warrantOf(v any) (*delivery.AddNodeWarrant, error) {
func warrantOf(v any) (*mma.Warrant, error) {
var raw []byte
switch w := v.(type) {
case nil:
@@ -253,7 +255,7 @@ func warrantOf(v any) (*delivery.AddNodeWarrant, error) {
return nil, err
}
}
var out delivery.AddNodeWarrant
var out mma.Warrant
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
if err := dec.Decode(&out); err != nil {
@@ -284,11 +286,11 @@ func (s *seatTools) doWithdraw(args map[string]any, by string) (any, error) {
return nil, err
}
p.Withdrawn = fmt.Sprintf("withdrawn by %s: %s", by, why)
raw, err := delivery.EncodePlacementProposal(p)
raw, err := plc.EncodeProposal(p)
if err != nil {
return nil, err
}
if err := s.st.put(delivery.PlacementProposalsState, p.ID, raw); err != nil {
if err := s.st.put(mma.StateProposals, p.ID, raw); err != nil {
return nil, fmt.Errorf("the proposal %s is not withdrawn: %w", p.ID, err)
}
return map[string]any{"proposal": p.ID, "withdrawn": p.Withdrawn}, nil
@@ -301,12 +303,12 @@ func (s *seatTools) doAgain(args map[string]any) (any, error) {
if err != nil {
return nil, err
}
p.Again = s.now().UTC()
raw, err := delivery.EncodePlacementProposal(p)
p.Again = ptr(s.now().UTC())
raw, err := plc.EncodeProposal(p)
if err != nil {
return nil, err
}
if err := s.st.put(delivery.PlacementProposalsState, p.ID, raw); err != nil {
if err := s.st.put(mma.StateProposals, p.ID, raw); err != nil {
return nil, fmt.Errorf("the proposal %s is not asked again: %w", p.ID, err)
}
return map[string]any{"proposal": p.ID, "again": p.Again, "follow": followProposal(p.ID)}, nil
@@ -324,11 +326,11 @@ type placementAnswer struct {
// historyEntry is one change of a key in the state's revisions.
type historyEntry struct {
Node string `json:"node"`
Revision uint64 `json:"revision"`
At time.Time `json:"at"`
Change delivery.PlacementChange `json:"change"`
Deleted bool `json:"deleted,omitempty"`
Node string `json:"node"`
Revision uint64 `json:"revision"`
At time.Time `json:"at"`
Change plc.Change `json:"change"`
Deleted bool `json:"deleted,omitempty"`
}
// doPlacement is the verb placement (§10, §6): one node's key, one module's nodes, or every key; with history, the
@@ -342,12 +344,12 @@ func (s *seatTools) doPlacement(args map[string]any) (any, error) {
return nil, err
}
node, module := arg(args, "node"), arg(args, "module")
out := placementAnswer{Keys: map[string]keyed{}, Count: len(all), History: delivery.PlacementHistory}
out := placementAnswer{Keys: map[string]keyed{}, Count: len(all), History: plc.PlacementHistory}
for n, p := range all {
if node != "" && n != node {
continue
}
if module != "" && !strings.Contains(" "+strings.Join(p.Assigned(), " ")+" ", " "+module+" ") {
if module != "" && !strings.Contains(" "+strings.Join(mmac.Assigned(p), " ")+" ", " "+module+" ") {
continue
}
out.Keys[n] = keyed{Placement: p, Revision: revs[n]}
@@ -394,7 +396,7 @@ func (s *seatTools) doPlacement(args map[string]any) (any, error) {
}
}
out.Older = fmt.Sprintf("the state keeps %d revisions of each key; an older change is in its placement-changed event on "+
"the assigner's stream %s, kept %d days", delivery.PlacementHistory, delivery.PlacementStream, delivery.PlacementStreamDays)
"the assigner's stream %s, kept %d days", plc.PlacementHistory, plc.Stream, plc.StreamDays)
return out, nil
}
@@ -402,18 +404,18 @@ func (s *seatTools) doPlacement(args map[string]any) (any, error) {
// verdict with the differences and the answers it judged, its ask), its candidates by node with their revisions, and
// every node's answer now.
type openProposal struct {
Proposal delivery.PlacementProposal `json:"proposal"`
Proposal mma.Proposal `json:"proposal"`
Candidates map[string]uint64 `json:"candidates"`
Answers map[string]delivery.CandidateAnswer `json:"answers,omitempty"`
}
// doProposals is the verb proposals (§10).
func (s *seatTools) doProposals(args map[string]any) (any, error) {
ids, err := s.st.keys(delivery.PlacementProposalsState)
ids, err := s.st.keys(mma.StateProposals)
if err != nil {
return nil, fmt.Errorf("the proposals cannot be listed: %w", err)
}
cands, err := s.st.keys(delivery.PlacementCandidatesState)
cands, err := s.st.keys(mma.StateCandidates)
if err != nil {
return nil, fmt.Errorf("the candidates cannot be listed: %w", err)
}
@@ -454,11 +456,11 @@ func (s *seatTools) doProposals(args map[string]any) (any, error) {
}
o := openProposal{Proposal: p, Candidates: map[string]uint64{}, Answers: answers[id]}
for _, k := range cands {
pid, node, err := delivery.ParsePlacementCandidateKey(k)
pid, node, err := plc.ParseCandidateKey(k)
if err != nil || pid != id {
continue
}
if _, rev, found, err := s.st.get(delivery.PlacementCandidatesState, k); err == nil && found {
if _, rev, found, err := s.st.get(mma.StateCandidates, k); err == nil && found {
o.Candidates[node] = rev
}
}
@@ -474,15 +476,15 @@ func (s *seatTools) doProposals(args map[string]any) (any, error) {
// only from a tool of that name), with the seat's own schemas.
func (s *seatTools) tools() []stdio.Tool {
run := map[string]func(map[string]any) (any, error){
meshmoduleassigner.VerbPlacement: s.doPlacement,
meshmoduleassigner.VerbProposals: s.doProposals,
meshmoduleassigner.VerbPropose: func(a map[string]any) (any, error) { return s.doPropose(a, stdio.CallerOf(a)) },
meshmoduleassigner.VerbWithdraw: func(a map[string]any) (any, error) { return s.doWithdraw(a, stdio.CallerOf(a)) },
meshmoduleassigner.VerbAgain: s.doAgain,
mma.VerbPlacement: s.doPlacement,
mma.VerbProposals: s.doProposals,
mma.VerbPropose: func(a map[string]any) (any, error) { return s.doPropose(a, stdio.CallerOf(a)) },
mma.VerbWithdraw: func(a map[string]any) (any, error) { return s.doWithdraw(a, stdio.CallerOf(a)) },
mma.VerbAgain: s.doAgain,
}
var out []stdio.Tool
for _, v := range meshmoduleassigner.Verbs() {
out = append(out, stdio.Tool{Name: meshmoduleassigner.Seat + "." + v, Run: run[v]})
for _, v := range mma.Verbs() {
out = append(out, stdio.Tool{Name: mma.Seat + "." + v, Run: run[v]})
}
return out
}
@@ -490,25 +492,25 @@ func (s *seatTools) tools() []stdio.Tool {
// seats is the verb placement with seats (ADR 0571 §4): who holds each seat and on which node, as the state seat-holders
// holds it; with node, the keys of seats held on that node.
func (s *seatTools) seats(node string) (any, error) {
keys, err := s.st.keys(delivery.SeatHoldersState)
keys, err := s.st.keys(mma.StateSeatHolders)
if err != nil {
return nil, fmt.Errorf("seat-holders cannot be listed: %w", err)
}
sort.Strings(keys)
out := []delivery.SeatHolders{}
out := []mma.SeatHolders{}
for _, key := range keys {
raw, _, found, err := s.st.get(delivery.SeatHoldersState, key)
raw, _, found, err := s.st.get(mma.StateSeatHolders, key)
if err != nil {
return nil, fmt.Errorf("seat-holders %s cannot be read: %w", key, err)
}
if !found {
continue
}
var sh delivery.SeatHolders
var sh mma.SeatHolders
if json.Unmarshal(raw, &sh) != nil {
continue
}
if node != "" && sh.Node != node && !slices.ContainsFunc(sh.Holders, func(h delivery.SeatHolder) bool { return h.Node == node }) {
if node != "" && sh.Node != node && !slices.ContainsFunc(sh.Holders, func(h mma.SeatHoldersHoldersItem) bool { return h.Node == node }) {
continue
}
out = append(out, sh)
@@ -526,17 +528,17 @@ type keyedRevision struct {
// `placement.<node>` before it was converted, then its node key's `<node>.node`, whose every revision is one write of
// the node (research 072 row 37: the node key is a write's commit, so its history is the node's placement history).
func (s *seatTools) nodeHistory(node string) ([]keyedRevision, error) {
legacy, err := delivery.LegacyPlacementKey(node)
legacy, err := mmac.LegacyKey(node)
if err != nil {
return nil, err
}
nodeKey, err := delivery.PlacementNodeKey(node)
nodeKey, err := mmac.NodeKey(node)
if err != nil {
return nil, err
}
var out []keyedRevision
for _, key := range []string{legacy, nodeKey} {
revisions, err := s.st.history(delivery.PlacementState, key)
revisions, err := s.st.history(mma.StatePlacement, key)
if err != nil {
return nil, fmt.Errorf("the history of %s cannot be read: %w", key, err)
}
@@ -549,43 +551,43 @@ func (s *seatTools) nodeHistory(node string) ([]keyedRevision, error) {
}
// sketch is a revision of a node's placement as far as its history says a change: each module's digest, and its pins
// and holdings, which change every module (delivery.ChangeOf).
// and holdings, which change every module (plc.ChangeOf).
type sketch struct {
modules []delivery.PlacementRef
modules []mma.PlacementByNodeNodeValueModulesItem
nodeWide string
}
// sketchOf reads one revision of a node key or a legacy key; a value that does not read places nothing.
func sketchOf(key string, value []byte) sketch {
_, _, kind, err := delivery.ParsePlacementKey(key)
_, _, kind, err := mmac.ParseKey(key)
if err != nil {
return sketch{}
}
wide := func(pins map[string]delivery.Provider, holds []delivery.Holding) string {
wide := func(pins map[string]mma.Provider, holds []mma.Holding) string {
raw, _ := json.Marshal([]any{pins, holds})
return string(raw)
}
if kind == delivery.PlacementNodeKind {
n, err := delivery.DecodePlacementNode(value)
if kind == mmac.NodeKind {
n, err := mmac.DecodeNode(value)
if err != nil {
return sketch{}
}
return sketch{modules: n.Modules, nodeWide: wide(n.Pins, n.Holds)}
}
p, err := delivery.DecodePlacement(value)
p, err := mmac.DecodePlacement(value)
if err != nil {
return sketch{}
}
out := sketch{nodeWide: wide(p.Pins, p.Holds)}
for _, m := range p.Modules {
out.modules = append(out.modules, delivery.PlacementRef{Module: m.Module, Digest: m.Digest()})
out.modules = append(out.modules, mma.PlacementByNodeNodeValueModulesItem{Module: m.Module, Digest: mmac.Digest(m)})
}
return out
}
// changeFrom is what changed from before to s on node, as delivery.ChangeOf says it.
func (s sketch) changeFrom(node string, before sketch) delivery.PlacementChange {
c := delivery.PlacementChange{Node: node}
// changeFrom is what changed from before to s on node, as plc.ChangeOf says it.
func (s sketch) changeFrom(node string, before sketch) plc.Change {
c := plc.Change{Node: node}
was := map[string]string{}
for _, r := range before.modules {
was[r.Module] = r.Digest
@@ -12,6 +12,8 @@ import (
"testing"
"time"
mmac "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient"
mma "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
@@ -20,6 +22,7 @@ import (
"github.com/nats-io/nats.go/jetstream"
"mesh-delivery-assigner/internal/assign"
plc "mesh-delivery-assigner/internal/placement"
)
func (s memStore) del(state, key string) error {
@@ -39,9 +42,9 @@ func (s memStore) history(state, key string) ([]stdio.StateRevision, error) {
// or refuses naming every finding and writes nothing; withdraw and again mark it for the holder; placement and
// proposals read every state the seat keeps, and history names the stream for what the state no longer keeps.
func TestTheSeatsVerbs(t *testing.T) {
anchor := delivery.Placement{V: delivery.Version, Node: "anchor", Modules: []delivery.PlacedModule{{Module: "web"}}}
anchor := mma.Placement{V: delivery.Version, Node: "anchor", Modules: []mma.PlacedModule{{Module: "web"}}}
st := memStore{}
for _, state := range []string{delivery.PlacementState, delivery.PlacementProposalsState, delivery.PlacementCandidatesState, modulesState, haveState} {
for _, state := range []string{mma.StatePlacement, mma.StateProposals, mma.StateCandidates, modulesState, haveState} {
st[state] = &memKV{values: map[string][]byte{}}
}
for _, m := range []string{"web", "shell"} {
@@ -58,8 +61,8 @@ func TestTheSeatsVerbs(t *testing.T) {
})
s.now = func() time.Time { return time.Date(2026, 10, 28, 20, 0, 0, 0, time.UTC) }
raw, _ := delivery.EncodePlacement(anchor)
st[delivery.PlacementState].values["placement.anchor"] = raw
raw, _ := mmac.EncodePlacement(anchor)
st[mma.StatePlacement].values["placement.anchor"] = raw
// withdraw and again mark the proposal; the holder ends or judges it.
got, err := s.doPropose(map[string]any{"why": "hold", "changes": `[{"op":"hold","node":"anchor","module":"web","claim":"node-shell","scope":"node"}]`}, "op")
@@ -73,18 +76,18 @@ func TestTheSeatsVerbs(t *testing.T) {
if _, err := s.doAgain(map[string]any{"id": seed.ID}); err != nil {
t.Fatal(err)
}
if p, err := s.proposal(seed.ID); err != nil || !strings.Contains(p.Withdrawn, "not yet") || p.Again.IsZero() {
if p, err := s.proposal(seed.ID); err != nil || !strings.Contains(p.Withdrawn, "not yet") || p.Again == nil || p.Again.IsZero() {
t.Errorf("marked = %+v, %v", p, err)
}
if _, err := s.doWithdraw(map[string]any{"id": "p-none", "why": "x"}, "op"); err == nil {
t.Error("an unknown proposal was withdrawn")
}
delete(st[delivery.PlacementProposalsState].values, seed.ID) // the holder ended it
st[delivery.PlacementCandidatesState].values = map[string][]byte{}
delete(st[mma.StateProposals].values, seed.ID) // the holder ended it
st[mma.StateCandidates].values = map[string][]byte{}
// propose: refused whole, naming every finding; a valid move laid over the keys at their revisions.
if _, err := s.doPropose(map[string]any{"why": "x", "changes": `[{"op":"place","node":"anchor","module":"ghost"},{"op":"hold","node":"anchor","module":"web","claim":"x"}]`}, "op"); err == nil ||
!strings.Contains(err.Error(), "ghost") || len(st[delivery.PlacementProposalsState].values) != 0 {
!strings.Contains(err.Error(), "ghost") || len(st[mma.StateProposals].values) != 0 {
t.Errorf("a refused proposal: %v", err)
}
if _, err := s.doPropose(map[string]any{"why": "x", "changes": `[{"op":"swap","node":"anchor"}]`}, "op"); err == nil {
@@ -96,17 +99,17 @@ func TestTheSeatsVerbs(t *testing.T) {
t.Fatal(err)
}
move := got.(proposed).Proposal
if move.By != "operator@laptop" || move.Base["anchor"] != uint64(len(raw)) || move.Base["laptop"] != 0 || len(st[delivery.PlacementCandidatesState].values) != 2 {
if move.By != "operator@laptop" || move.Base["anchor"] != int64(len(raw)) || move.Base["laptop"] != 0 || len(st[mma.StateCandidates].values) != 2 {
t.Fatalf("propose = %+v", move)
}
// placement and proposals read every state the seat keeps (§10); history names the stream for what is older.
reads := map[string]bool{}
pa, err := s.doPlacement(map[string]any{"module": "web", "history": "true"})
if a := pa.(placementAnswer); err != nil || !slices.Equal(a.Nodes, []string{"anchor"}) || len(a.Changes) != 1 || !strings.Contains(a.Older, delivery.PlacementStream) {
if a := pa.(placementAnswer); err != nil || !slices.Equal(a.Nodes, []string{"anchor"}) || len(a.Changes) != 1 || !strings.Contains(a.Older, plc.Stream) {
t.Errorf("placement of web = %+v, %v", pa, err)
} else {
reads[delivery.PlacementState], reads["stream "+delivery.PlacementStream] = true, true
reads[mma.StatePlacement], reads["stream "+plc.Stream] = true, true
}
if _, err := s.doPlacement(map[string]any{"node": "laptop"}); err == nil {
t.Error("a node with no key was answered")
@@ -115,14 +118,14 @@ func TestTheSeatsVerbs(t *testing.T) {
if list := ps.(map[string]any)["proposals"].([]openProposal); err != nil || len(list) != 1 || len(list[0].Candidates) != 2 {
t.Errorf("proposals = %+v, %v", ps, err)
} else {
reads[delivery.PlacementProposalsState], reads[delivery.PlacementCandidatesState] = true, true
reads[mma.StateProposals], reads[mma.StateCandidates] = true, true
}
raw0, err := readManifest()
if err != nil {
t.Fatal(err)
}
for _, state := range raw0.State {
if slices.Contains([]string{delivery.PlacementState, delivery.PlacementProposalsState, delivery.PlacementCandidatesState}, state.Name) && !reads[state.Name] {
if slices.Contains([]string{mma.StatePlacement, mma.StateProposals, mma.StateCandidates}, state.Name) && !reads[state.Name] {
t.Errorf("no verb reads the seat's state %s", state.Name)
}
}
@@ -161,8 +164,8 @@ func TestTheSeatsVerbs(t *testing.T) {
// A remove-node (the operator's decision of 2026-11-02 01:05) takes a joining node that never confirmed off placement: from
// mesh-enrolment, on the warrant naming that node, of a node running nothing, it is proposed with the node's key to delete;
// of a node running modules, or with another node's warrant, it is refused.
joiner, _ := delivery.EncodePlacement(delivery.Placement{V: delivery.Version, Node: "joiner", Decided: delivery.NodeDecided{Address: "10.9.0.7"}, Modules: []delivery.PlacedModule{}})
st[delivery.PlacementState].values["placement.joiner"] = joiner
joiner, _ := mmac.EncodePlacement(mma.Placement{V: delivery.Version, Node: "joiner", Decided: mma.NodeDecided{Address: "10.9.0.7"}, Modules: []mma.PlacedModule{}})
st[mma.StatePlacement].values["placement.joiner"] = joiner
removeNode := func(node, warrant string) (any, error) {
return s.doPropose(map[string]any{"why": "enrolment ended", "warrant": warrant,
"changes": []any{map[string]any{"op": "remove-node", "node": node}}}, "novox.mesh-enrolment")
@@ -177,7 +180,7 @@ func TestTheSeatsVerbs(t *testing.T) {
}
if got, err := removeNode("joiner", `{"node":"joiner","address":"10.9.0.7","ask":"a","warrant":"a@t"}`); err != nil {
t.Errorf("a remove-node from mesh-enrolment: %v", err)
} else if rm := got.(proposed).Proposal; !slices.Equal(rm.Nodes(), []string{"joiner"}) || !slices.Equal(rm.RemovedNodes(), []string{"joiner"}) {
} else if rm := got.(proposed).Proposal; !slices.Equal(plc.Nodes(rm), []string{"joiner"}) || !slices.Equal(plc.RemovedNodes(rm), []string{"joiner"}) {
t.Errorf("the remove-node proposed %+v", rm)
}
// 1126/B8: the seat serves its five verbs; adopt and hand-back went with the controller (ADR 0590).
@@ -188,9 +191,9 @@ func TestTheSeatsVerbs(t *testing.T) {
t.Run("1955/L7 (ADR 0571 §6): rule 7 reads the node's own composition; an unplaced node is said", func(t *testing.T) {
w := newWorld()
fp := delivery.SourcePrefix2 + strings.Repeat("2", 64)
pl, _ := delivery.EncodePlacement(delivery.Placement{V: delivery.Version, Node: "ace", Modules: []delivery.PlacedModule{{Module: "web"}}})
pkey, _ := delivery.LegacyPlacementKey("ace")
w.putEntry(delivery.PlacementBucket, pkey, pl, 1, time.Now())
pl, _ := mmac.EncodePlacement(mma.Placement{V: delivery.Version, Node: "ace", Modules: []mma.PlacedModule{{Module: "web"}}})
pkey, _ := mmac.LegacyKey("ace")
w.putEntry(plc.PlacementBucket, pkey, pl, 1, time.Now())
for node, digest := range map[string]string{"ace": "sha256:" + strings.Repeat("a", 64), "g14": ""} {
hn, _ := delivery.EncodeHaveNode(delivery.HaveNode{Composed: digest})
hk, _ := delivery.HaveNodeKey(node)
@@ -210,9 +213,9 @@ func TestTheSeatsVerbs(t *testing.T) {
if _, ok := findings[assign.UnplacedKey("g14")]; !ok || len(findings) != 1 {
t.Fatalf("findings = %v", findings)
}
pl2, _ := delivery.EncodePlacement(delivery.Placement{V: delivery.Version, Node: "g14", Modules: []delivery.PlacedModule{{Module: "web"}}})
pkey2, _ := delivery.LegacyPlacementKey("g14")
w.putEntry(delivery.PlacementBucket, pkey2, pl2, 1, time.Now())
pl2, _ := mmac.EncodePlacement(mma.Placement{V: delivery.Version, Node: "g14", Modules: []mma.PlacedModule{{Module: "web"}}})
pkey2, _ := mmac.LegacyKey("g14")
w.putEntry(plc.PlacementBucket, pkey2, pl2, 1, time.Now())
p.unplacedFindings()
if len(findings) != 0 {
t.Errorf("placed again: findings %v", findings)
@@ -221,17 +224,17 @@ func TestTheSeatsVerbs(t *testing.T) {
t.Run("1955/L7 (ADR 0571 §4): seat-holders is written from placement, only the keys a change moves, and answered by placement seats", func(t *testing.T) {
writes := 0
kv := &countingKV{memKV: memKV{values: map[string][]byte{}}, writes: &writes}
p := &passer{w: newWorld(), logf: t.Logf, kvs: buckets{delivery.SeatHoldersBucket: kv}}
place := func(node string, holds ...delivery.Holding) {
pl, err := delivery.EncodePlacement(delivery.Placement{V: delivery.Version, Node: node, Modules: []delivery.PlacedModule{{Module: "web"}, {Module: "nats"}}, Holds: holds})
p := &passer{w: newWorld(), logf: t.Logf, kvs: buckets{plc.SeatHoldersBucket: kv}}
place := func(node string, holds ...mma.Holding) {
pl, err := mmac.EncodePlacement(mma.Placement{V: delivery.Version, Node: node, Modules: []mma.PlacedModule{{Module: "web"}, {Module: "nats"}}, Holds: holds})
if err != nil {
t.Fatal(err)
}
k, _ := delivery.LegacyPlacementKey(node)
p.w.putEntry(delivery.PlacementBucket, k, pl, 1, time.Now())
k, _ := mmac.LegacyKey(node)
p.w.putEntry(plc.PlacementBucket, k, pl, 1, time.Now())
}
place("ace", delivery.Holding{Claim: "mesh-broker", Scope: "mesh", Module: "nats"}, delivery.Holding{Claim: "node-shell", Scope: "node", Module: "web"})
place("g14", delivery.Holding{Claim: "node-shell", Scope: "node", Module: "web"})
place("ace", mma.Holding{Claim: "mesh-broker", Scope: "mesh", Module: "nats"}, mma.Holding{Claim: "node-shell", Scope: "node", Module: "web"})
place("g14", mma.Holding{Claim: "node-shell", Scope: "node", Module: "web"})
ctx := context.Background()
p.seatHolders(ctx)
if writes != 3 || kv.values["mesh-broker"] == nil || kv.values["node-shell.ace"] == nil || kv.values["node-shell.g14"] == nil {
@@ -246,9 +249,9 @@ func TestTheSeatsVerbs(t *testing.T) {
if writes != 3 || kv.values["node-shell.g14"] != nil || kv.values["node-shell.ace"] == nil {
t.Errorf("after g14's change: %d writes, %v", writes, kv.values)
}
st := memStore{delivery.SeatHoldersState: &memKV{values: kv.values}}
st := memStore{mma.StateSeatHolders: &memKV{values: kv.values}}
out, err := newSeatTools(st, nil).seats("ace")
if err != nil || len(out.(map[string]any)["seats"].([]delivery.SeatHolders)) != 2 {
if err != nil || len(out.(map[string]any)["seats"].([]mma.SeatHolders)) != 2 {
t.Errorf("seats on ace = %+v, %v", out, err)
}
})
@@ -256,13 +259,13 @@ func TestTheSeatsVerbs(t *testing.T) {
ctx := context.Background()
kv := &revKV{values: map[string][]byte{}, revs: map[string]uint64{}}
var logs []string
p := &passer{w: newWorld(), kvs: buckets{delivery.PlacementBucket: kv}, now: time.Now,
p := &passer{w: newWorld(), kvs: buckets{plc.PlacementBucket: kv}, now: time.Now,
logf: func(f string, a ...any) { logs = append(logs, fmt.Sprintf(f, a...)) }}
put := func(key string, value []byte, rev uint64) {
p.w.putEntry(delivery.PlacementBucket, key, value, rev, time.Now())
p.w.putEntry(plc.PlacementBucket, key, value, rev, time.Now())
}
was := delivery.Placement{Node: "anchor", Modules: []delivery.PlacedModule{{Module: "web"}}}
raw, _ := delivery.EncodePlacement(was)
was := mma.Placement{Node: "anchor", Modules: []mma.PlacedModule{{Module: "web"}}}
raw, _ := mmac.EncodePlacement(was)
put("placement.anchor", raw, kv.write("placement.anchor", raw))
p.convertPlacement(ctx)
if strings.Join(kv.log, " ") != "create anchor.web create anchor.node" || !p.convertedAll ||
@@ -274,28 +277,28 @@ func TestTheSeatsVerbs(t *testing.T) {
t.Errorf("a converted node is converted again: %v", kv.log)
}
// A write: the module keys, the node key (its commit), the mirror, then the module key no longer placed.
after := delivery.Placement{Node: "anchor", Modules: []delivery.PlacedModule{{Module: "shell", Ports: map[string]int{"22": 2222}}}}
after := mma.Placement{Node: "anchor", Modules: []mma.PlacedModule{{Module: "shell", Ports: map[string]int64{"22": 2222}}}}
kv.log = nil
rev, err := p.writeNode(ctx, "anchor", &after)
if err != nil || strings.Join(kv.log, " ") != "create anchor.shell update anchor.node update placement.anchor delete anchor.web" || rev != kv.revs["anchor.node"] {
t.Fatalf("the write = %v, %d, %v", kv.log, rev, err)
}
if got, _ := p.seatPlacements(); !delivery.SamePlacement(got["anchor"], after) {
if got, _ := p.seatPlacements(); !mmac.SamePlacement(got["anchor"], after) {
t.Errorf("the write is not read back at once: %+v", got)
}
// A commit refused leaves every reader on what stood: the module key written before it carries the value it replaced.
kv.refuse, kv.log = "anchor.node", nil
moved := delivery.Placement{Node: "anchor", Modules: []delivery.PlacedModule{{Module: "shell", Ports: map[string]int{"22": 2223}}}}
moved := mma.Placement{Node: "anchor", Modules: []mma.PlacedModule{{Module: "shell", Ports: map[string]int64{"22": 2223}}}}
if _, err := p.writeNode(ctx, "anchor", &moved); err == nil || strings.Join(kv.log, " ") != "update anchor.shell" {
t.Fatalf("a refused commit = %v, %v", kv.log, err)
}
fresh, err := delivery.PlacementsOf(kv.values)
if err != nil || !delivery.SamePlacement(fresh["anchor"], after) {
fresh, err := mmac.PlacementsOf(kv.values)
if err != nil || !mmac.SamePlacement(fresh["anchor"], after) {
t.Errorf("a reader of the keys after a refused commit reads %+v, %v", fresh["anchor"], err)
}
kv.refuse = ""
// The verb's history reads the form before, then the node key.
st := memStore{delivery.PlacementState: &memKV{values: kv.values}}
st := memStore{mma.StatePlacement: &memKV{values: kv.values}}
hs := newSeatTools(st, nil)
hs.st = historyStore{memStore: st, revisions: map[string][]stdio.StateRevision{
"placement.anchor": {{Key: "placement.anchor", Value: raw, Revision: 1, Op: "put"}},
@@ -339,7 +342,7 @@ func TestTheSeatsVerbs(t *testing.T) {
}
// A required provision with no key: pulsed with the bound first, said once the pulse expired, ended once keyed.
ctx := context.Background()
p.kvs[delivery.PlacementPulsesBucket] = &memKV{values: map[string][]byte{}}
p.kvs[plc.PulsesBucket] = &memKV{values: map[string][]byte{}}
gone := map[string]bool{"ace.photos.s3": true}
p.provisionsMissing(ctx, gone)
if !p.pulsing("provision-missing.ace.photos.s3") || len(findings) != 0 {
@@ -349,7 +352,7 @@ func TestTheSeatsVerbs(t *testing.T) {
if len(findings) != 0 {
t.Fatal("said before its bound")
}
p.w.put(delivery.PlacementPulsesBucket, "provision-missing.ace.photos.s3", nil) // the server expired it
p.w.put(plc.PulsesBucket, "provision-missing.ace.photos.s3", nil) // the server expired it
p.provisionsMissing(ctx, gone)
if _, ok := findings[assign.ProvisionMissingKey("ace.photos.s3")]; !ok {
t.Fatalf("past its bound: findings %v", findings)
@@ -9,6 +9,7 @@ import (
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
mma "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
@@ -21,6 +22,7 @@ import (
"git.novox.be/novox/mesh-delivery/sdk/heard"
"mesh-delivery-assigner/internal/assign"
plc "mesh-delivery-assigner/internal/placement"
)
// marksBucket is the assigner's state of what its views family said each state up to.
@@ -32,15 +34,15 @@ type viewedTool struct{ tool, state, words string }
// firstViewTools are the assigner's first-view tools (ADR 0621 §1): each answers only its callers list
// (views.CallersEnv), which the manifest writes.
var firstViewTools = []viewedTool{
{"placement", delivery.PlacementState, "where each module is placed: the seat mesh-placement's placement, by module."},
{"seat-holders", delivery.SeatHoldersState, "who holds which seat on which node."},
{"placement", mma.StatePlacement, "where each module is placed: the seat mesh-placement's placement, by module."},
{"seat-holders", mma.StateSeatHolders, "who holds which seat on which node."},
{"wants", "wants", "what each node should run: want.<node>.<module>, its fingerprint and the rule that gave it."},
}
// readTools are the assigner's ordinary read tools, one per state its readers read (design 25 § 12.2).
var readTools = []viewedTool{
{"assigner_read_placement", delivery.PlacementState, "where each module is placed, by module."},
{"assigner_read_seat_holders", delivery.SeatHoldersState, "who holds which seat on which node."},
{"assigner_read_placement", mma.StatePlacement, "where each module is placed, by module."},
{"assigner_read_seat_holders", mma.StateSeatHolders, "who holds which seat on which node."},
{"assigner_read_wants", "wants", "what each node should run: want.<node>.<module>."},
{"assigner_read_findings", health.FindingsState, "the assigner's findings, which the condition keeper raises and clears."},
{"assigner_read_asks", "asks", "the asks the assigner put to a person, and their answers."},
@@ -49,12 +51,12 @@ var readTools = []viewedTool{
// viewedStates are the states the views family says, each by its bucket: the six its readers read.
var viewedStates = map[string]string{
delivery.PlacementState: delivery.PlacementBucket,
delivery.SeatHoldersState: delivery.SeatHoldersBucket,
"wants": delivery.AssignerWantsBucket,
health.FindingsState: assign.FindingsBucket,
"asks": delivery.AssignerAsksBucket,
"differences": delivery.AssignerDifferencesBucket,
mma.StatePlacement: plc.PlacementBucket,
mma.StateSeatHolders: plc.SeatHoldersBucket,
"wants": delivery.AssignerWantsBucket,
health.FindingsState: assign.FindingsBucket,
"asks": delivery.AssignerAsksBucket,
"differences": delivery.AssignerDifferencesBucket,
}
// viewTools are the assigner's first-view and read tools, reading only its own states, so they answer as soon as its
@@ -87,7 +89,7 @@ func startViews(ctx context.Context, js jetstream.JetStream, nc process.Subscrib
}
process.AwaitBucket(ctx, js, nc, marksBucket, "no change is said on the views family, so every reader waits on its first view", logf).Ready(func(mk jetstream.KeyValue) {
em := viewsEmitter(js, account, mk, findings, tooLarge, logf)
for _, state := range []string{"asks", "differences", health.FindingsState, delivery.PlacementState, delivery.SeatHoldersState, "wants"} {
for _, state := range []string{"asks", "differences", health.FindingsState, mma.StatePlacement, mma.StateSeatHolders, "wants"} {
state := state
start := func(kv jetstream.KeyValue) {
if err := em.Start(ctx, state, kv, errs); err != nil {
@@ -11,10 +11,13 @@ import (
"github.com/nats-io/nats.go/jetstream"
mmac "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient"
mma "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"mesh-delivery-assigner/internal/assign"
plc "mesh-delivery-assigner/internal/placement"
)
// noModuleWords is the indexer's reason for a module of a candidate whose directory the merge preview holds no manifest
@@ -36,8 +39,8 @@ type world struct {
// trunkBuilt is, by module, its newest done trunk build in the builder's log (putBuilt).
trunkBuilt map[string]delivery.BuildLogEntry
// placement is the seat's state placement assembled node by node from its keys in either form (mesh-sdk
// delivery.PlacementKeys; research 072 row 37): the one reader of its keys in the assigner.
placement *delivery.PlacementKeys
// mmac.Keys; research 072 row 37): the one reader of its keys in the assigner.
placement *mmac.Keys
// sourceGroups is, by revision of the gate's source.main, the release (group) that moved it, from every revision its
// history keeps (watched with history): a build's source revision names the release that carries it (ADR 0601 §1).
sourceGroups map[uint64]string
@@ -58,7 +61,7 @@ type parsed struct {
func newWorld() *world {
return &world{values: map[string]map[string][]byte{}, manifests: map[string]parsed{}, healthy: map[string][]string{}, changes: map[string]uint64{},
meta: map[string]entryMeta{}, trunkBuilt: map[string]delivery.BuildLogEntry{}, placement: delivery.NewPlacementKeys(),
meta: map[string]entryMeta{}, trunkBuilt: map[string]delivery.BuildLogEntry{}, placement: mmac.NewKeys(),
sourceGroups: map[uint64]string{}}
}
@@ -72,7 +75,7 @@ func (w *world) putEntry(bucket, key string, value []byte, revision uint64, at t
return
}
w.meta[bucket+"|"+key] = entryMeta{revision: revision, at: at}
if bucket == delivery.PlacementBucket {
if bucket == plc.PlacementBucket {
_, _ = w.placement.Put(key, value, revision, false) // put took it already; this is its revision
}
if bucket == delivery.SourceBucket && key == delivery.SourceMainKey {
@@ -132,14 +135,14 @@ func (w *world) put(bucket, key string, value []byte) {
w.changes[bucket]++ // the bucket's generation (bucketGen), a delete included
if value == nil {
delete(w.values[bucket], key)
if bucket == delivery.PlacementBucket {
if bucket == plc.PlacementBucket {
_, _ = w.placement.Put(key, nil, 0, true)
}
return
}
w.values[bucket][key] = value
w.changes[bucket+"|"+key]++
if bucket == delivery.PlacementBucket {
if bucket == plc.PlacementBucket {
// A key or value that does not read places nothing new on its node; the indexer's read tool says it.
_, _ = w.placement.Put(key, value, 0, false)
}
@@ -262,13 +265,13 @@ func (w *world) assigned() map[string][]string {
func (w *world) placed() (map[string][]string, bool) {
out := map[string][]string{}
for node, p := range w.placements() {
out[node] = p.Assigned()
out[node] = mmac.Assigned(p)
}
return out, len(w.read(delivery.PlacementBucket)) > 0
return out, len(w.read(plc.PlacementBucket)) > 0
}
// placements is every node's placement, assembled from its keys in either form (delivery.PlacementKeys).
func (w *world) placements() map[string]delivery.Placement {
// placements is every node's placement, assembled from its keys in either form (mmac.Keys).
func (w *world) placements() map[string]mma.Placement {
out, _ := w.placement.AllRevisions()
return out
}
+1
View File
@@ -4,6 +4,7 @@ go 1.26.0
require (
git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient v0.0.0
git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000
@@ -16,6 +16,11 @@ import (
// 1297/B9 (the assigner's half): the ask about a held fingerprint is a valid ask of the router and of mesh-sdk's layout at
// its key, of a kind the view may answer.
func TestTheHoldAskIsValid(t *testing.T) {
t.Run("hold", theHoldAskIsValid)
t.Run("restore", theRestoreAskIsValid)
}
func theHoldAskIsValid(t *testing.T) {
key, err := delivery.HoldAskKey("mail", fNew)
if err != nil {
t.Fatal(err)
@@ -62,7 +67,7 @@ func TestTheHoldAskIsValid(t *testing.T) {
// 1906/D3 (ADR 0539 §19): the ask about a data module held on a node is a valid ask of the router and of mesh-sdk's layout at
// its restore key, of a kind the view may answer; it offers restoring (losing the newer writes) and leaving it stopped.
func TestTheRestoreAskIsValid(t *testing.T) {
func theRestoreAskIsValid(t *testing.T) {
key, err := delivery.RestoreAskKey("ace", "mail", fNew)
if err != nil {
t.Fatal(err)
@@ -9,7 +9,11 @@ import (
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
mmac "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient"
mma "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-sdk/go/delivery"
plc "mesh-delivery-assigner/internal/placement"
)
// The seat mesh-placement (novox/hq ADR 0567), held by the assigner beside its wants: placement is its state
@@ -22,13 +26,13 @@ import (
type ProposeIn struct {
ID, By, Why string
At time.Time
Changes []delivery.PlacementProposalChange
Current map[string]delivery.Placement
Changes []mma.ProposalChange
Current map[string]mma.Placement
Revisions map[string]uint64
Held func(module string) bool
Recorded func(node string) bool
// Warrant is the enrolment's warrant an add-node carries (ADR 0587 §7); only that change has one.
Warrant *delivery.AddNodeWarrant
Warrant *mma.Warrant
}
// Propose lays a proposal over the current keys (§2): the proposal (state answering, each changed node's base revision),
@@ -36,9 +40,9 @@ type ProposeIn struct {
// CheckPlacements refuses of the result laid over every placement (two modules on one host port, a provider not placed,
// a seat held once held twice, two nodes with one address, two hubs, a module the indexer does not hold, a node with no
// record). A key whose value the proposal leaves as it is changes nothing and is not laid.
func Propose(in ProposeIn) (delivery.PlacementProposal, map[string]*delivery.Placement, []string) {
p := delivery.PlacementProposal{ID: in.ID, By: strings.TrimSpace(in.By), Why: strings.TrimSpace(in.Why), At: in.At.UTC(),
Changes: in.Changes, Warrant: in.Warrant, Base: map[string]uint64{}, State: delivery.ProposalAnswering}
func Propose(in ProposeIn) (mma.Proposal, map[string]*mma.Placement, []string) {
p := mma.Proposal{ID: in.ID, By: strings.TrimSpace(in.By), Why: strings.TrimSpace(in.Why), At: in.At.UTC(),
Changes: in.Changes, Warrant: in.Warrant, Base: map[string]int64{}, State: plc.ProposalAnswering}
var findings []string
if p.By == "" {
findings = append(findings, "the runtime names no caller: a proposal is made by someone")
@@ -50,36 +54,36 @@ func Propose(in ProposeIn) (delivery.PlacementProposal, map[string]*delivery.Pla
findings = append(findings, "a proposal changes something: it names no change")
}
for i, c := range in.Changes {
if c.Op == delivery.OpSeed {
if c.Op == plc.OpSeed {
findings = append(findings, fmt.Sprintf("change %d: the seed is gone with adopt, the controller's export being gone", i+1))
}
}
laid, lay := delivery.LayChanges(in.Current, in.Changes)
laid, lay := plc.LayChanges(in.Current, in.Changes)
findings = append(findings, lay...)
findings = append(findings, ControllerRefusals(in.Changes, laid)...)
all := map[string]delivery.Placement{}
all := map[string]mma.Placement{}
for node, pl := range in.Current {
all[node] = pl
}
out := map[string]*delivery.Placement{}
out := map[string]*mma.Placement{}
for node, pl := range laid {
if before, ok := in.Current[node]; ok && delivery.SamePlacement(before, pl) {
if before, ok := in.Current[node]; ok && mmac.SamePlacement(before, pl) {
continue
}
pl := pl
all[node] = pl
out[node] = &pl
p.Base[node] = in.Revisions[node]
p.Base[node] = int64(in.Revisions[node])
}
// A remove-node deletes the key of a joining node that never confirmed (the operator's decision of 2026-11-02 01:05): its
// candidate is no placement, and the checks across nodes read the mesh without it. LayChanges said why one cannot go.
for _, node := range p.RemovedNodes() {
for _, node := range plc.RemovedNodes(p) {
if _, ok := in.Current[node]; !ok {
continue
}
delete(all, node)
out[node] = nil
p.Base[node] = in.Revisions[node]
p.Base[node] = int64(in.Revisions[node])
}
if len(findings) == 0 && len(out) == 0 {
findings = append(findings, "the changes leave every placement as it is")
@@ -95,7 +99,7 @@ func Propose(in ProposeIn) (delivery.PlacementProposal, map[string]*delivery.Pla
// this one caller is the exception to a node with no record (ADR 0587 §7, changing ADR 0567 §2).
joining := map[string]bool{}
for _, c := range in.Changes {
if c.Op == delivery.OpAddNode {
if c.Op == plc.OpAddNode {
joining[c.Node] = true
}
}
@@ -103,7 +107,7 @@ func Propose(in ProposeIn) (delivery.PlacementProposal, map[string]*delivery.Pla
was := recorded
recorded = func(node string) bool { return joining[node] || was(node) }
}
for _, r := range delivery.CheckPlacements(all, held, recorded) {
for _, r := range plc.CheckPlacements(all, held, recorded) {
findings = append(findings, r)
}
return p, out, findings
@@ -119,12 +123,12 @@ const ControllerEnded = "the controller ended by its unassignment on 2026-11-02;
// ControllerRefusals names every change, and every node as laid, that places the controller: by a place, by a seed's
// whole placement, or in what the changes lay. None when nothing does.
func ControllerRefusals(changes []delivery.PlacementProposalChange, laid map[string]delivery.Placement) []string {
func ControllerRefusals(changes []mma.ProposalChange, laid map[string]mma.Placement) []string {
var out []string
nodes := map[string]bool{}
for i, c := range changes {
places := c.Module == ControllerModule && c.Op != delivery.OpTakeOff
if c.Whole != nil && slices.Contains(c.Whole.Assigned(), ControllerModule) {
places := c.Module == ControllerModule && c.Op != plc.OpTakeOff
if c.Whole != nil && slices.Contains(mmac.Assigned(*c.Whole), ControllerModule) {
places = true
}
if places {
@@ -133,7 +137,7 @@ func ControllerRefusals(changes []delivery.PlacementProposalChange, laid map[str
}
}
for _, node := range slices.Sorted(maps.Keys(laid)) {
if !nodes[node] && slices.Contains(laid[node].Assigned(), ControllerModule) {
if !nodes[node] && slices.Contains(mmac.Assigned(laid[node]), ControllerModule) {
out = append(out, fmt.Sprintf("%s would run %s: %s", node, ControllerModule, ControllerEnded))
}
}
@@ -141,23 +145,23 @@ func ControllerRefusals(changes []delivery.PlacementProposalChange, laid map[str
}
// Write is one node a warrant writes (§5): the node, its node key `<node>.node` (the commit of the node's keys,
// mesh-sdk delivery.PlacementKeys.Writes), the revision of the node's placement it is written at (0: it has none, so
// mesh-sdk mmac.Keys.Writes), the revision of the node's placement it is written at (0: it has none, so
// it is created) and its value (nil: it is deleted).
type Write struct {
Node, Key string
Base uint64
Value *delivery.Placement
Value *mma.Placement
}
// Writes is every key a proposal writes, by node order.
func Writes(p delivery.PlacementProposal, laid map[string]*delivery.Placement) ([]Write, error) {
func Writes(p mma.Proposal, laid map[string]*mma.Placement) ([]Write, error) {
var out []Write
for _, node := range p.Nodes() {
key, err := delivery.PlacementNodeKey(node)
for _, node := range plc.Nodes(p) {
key, err := mmac.NodeKey(node)
if err != nil {
return nil, err
}
out = append(out, Write{Node: node, Key: key, Base: p.Base[node], Value: laid[node]})
out = append(out, Write{Node: node, Key: key, Base: uint64(p.Base[node]), Value: laid[node]})
}
return out, nil
}
@@ -178,24 +182,24 @@ func CallClearedCondition(kind, node, module string) oc.Condition {
}
// PlacementAct is the act an approved proposal's yes binds: writing exactly its changes at its base revisions.
func PlacementAct(p delivery.PlacementProposal) occ.Act {
return occ.Act{"placement": "write", "proposal": p.ID, "changes": delivery.ChangesDigest(p)}
func PlacementAct(p mma.Proposal) occ.Act {
return occ.Act{"placement": "write", "proposal": p.ID, "changes": plc.ChangesDigest(p)}
}
// PlacementAsking is a proposal's asking number: each judging that raises its ask again (again, a key moved) is
// another asking, under an id of its own.
func PlacementAsking(p delivery.PlacementProposal, at time.Time) int {
func PlacementAsking(p mma.Proposal, at time.Time) int {
return int(at.Unix())
}
// PlacementAsk is the authorising ask about a proposal (§5): an Approve that binds its act, so only a channel that
// proves who answered takes it, with the brief of the verdict by node (judge.Brief) as its explanation.
func PlacementAsk(key string, p delivery.PlacementProposal, brief string, asking int, now time.Time) (delivery.AssignerAsk, error) {
func PlacementAsk(key string, p mma.Proposal, brief string, asking int, now time.Time) (delivery.AssignerAsk, error) {
binds, err := occ.ActDigest(PlacementAct(p))
if err != nil {
return delivery.AssignerAsk{}, err
}
nodes := p.Nodes()
nodes := plc.Nodes(p)
headline := fmt.Sprintf("Change placement on %s?", strings.Join(nodes, ", "))
if n := len([]rune(headline)); n > occ.HeadlineLength {
headline = fmt.Sprintf("Change placement on %d machines?", len(nodes))
@@ -218,9 +222,9 @@ func PlacementAsk(key string, p delivery.PlacementProposal, brief string, asking
// Rest is what a proposal leaves to write once the keys of written are: its changes of the other nodes, to be laid again
// over the keys as the write left them (§5: a key moved while it was written). False when nothing is left.
func Rest(p delivery.PlacementProposal, written map[string]*delivery.Placement) (delivery.PlacementProposal, bool) {
func Rest(p mma.Proposal, written map[string]*mma.Placement) (mma.Proposal, bool) {
rest := p
rest.Changes, rest.Base = nil, map[string]uint64{}
rest.Changes, rest.Base = nil, map[string]int64{}
for _, c := range p.Changes {
if _, done := written[c.Node]; !done {
rest.Changes = append(rest.Changes, c)
@@ -231,6 +235,6 @@ func Rest(p delivery.PlacementProposal, written map[string]*delivery.Placement)
rest.Base[node] = base
}
}
rest.Must, rest.Verdict, rest.Ask, rest.State = nil, nil, "", delivery.ProposalAnswering
rest.Must, rest.Verdict, rest.Ask, rest.State = nil, nil, "", plc.ProposalAnswering
return rest, len(rest.Changes) > 0
}
@@ -8,47 +8,51 @@ import (
"time"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
mmac "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient"
mma "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-sdk/go/delivery"
plc "mesh-delivery-assigner/internal/placement"
)
// ADR 0567 §2, §5, §7, §8: what the seat's holder decides of a proposal: every refusal named at once, a valid one laid
// with the base revision of each key it changes, the writes at those revisions, the controller's calls that keep its
// assignments in step, the authorising ask bound to the changes, and the export read as the controller answers it.
func TestThePlacementSeatDecides(t *testing.T) {
anchor := delivery.Placement{V: delivery.Version, Node: "anchor", Decided: delivery.NodeDecided{Hub: true, Address: "10.0.0.1"},
Modules: []delivery.PlacedModule{{Module: "store", Ports: map[string]int{"5432": 5432}}, {Module: "web"}}}
laptop := delivery.Placement{V: delivery.Version, Node: "laptop", Decided: delivery.NodeDecided{Address: "10.0.0.2"}, Modules: []delivery.PlacedModule{{Module: "shell"}}}
in := func(changes ...delivery.PlacementProposalChange) ProposeIn {
anchor := mma.Placement{V: delivery.Version, Node: "anchor", Decided: mma.NodeDecided{Hub: true, Address: "10.0.0.1"},
Modules: []mma.PlacedModule{{Module: "store", Ports: map[string]int64{"5432": 5432}}, {Module: "web"}}}
laptop := mma.Placement{V: delivery.Version, Node: "laptop", Decided: mma.NodeDecided{Address: "10.0.0.2"}, Modules: []mma.PlacedModule{{Module: "shell"}}}
in := func(changes ...mma.ProposalChange) ProposeIn {
return ProposeIn{ID: "p-one", By: "operator@laptop", Why: "move web", At: time.Unix(1, 0), Changes: changes,
Current: map[string]delivery.Placement{"anchor": anchor, "laptop": laptop}, Revisions: map[string]uint64{"anchor": 4, "laptop": 9},
Current: map[string]mma.Placement{"anchor": anchor, "laptop": laptop}, Revisions: map[string]uint64{"anchor": 4, "laptop": 9},
Held: func(m string) bool { return m != "ghost" },
Recorded: func(n string) bool { return n == "anchor" || n == "laptop" }}
}
move := []delivery.PlacementProposalChange{{Op: delivery.OpTakeOff, Node: "anchor", Module: "web"}, {Op: delivery.OpPlace, Node: "laptop", Module: "web"}}
move := []mma.ProposalChange{{Op: plc.OpTakeOff, Node: "anchor", Module: "web"}, {Op: plc.OpPlace, Node: "laptop", Module: "web"}}
p, laid, findings := Propose(in(move...))
if len(findings) != 0 || p.State != delivery.ProposalAnswering || p.Base["anchor"] != 4 || p.Base["laptop"] != 9 ||
!slices.Equal(laid["laptop"].Assigned(), []string{"shell", "web"}) || !slices.Equal(laid["anchor"].Assigned(), []string{"store"}) {
if len(findings) != 0 || p.State != plc.ProposalAnswering || p.Base["anchor"] != 4 || p.Base["laptop"] != 9 ||
!slices.Equal(mmac.Assigned(*laid["laptop"]), []string{"shell", "web"}) || !slices.Equal(mmac.Assigned(*laid["anchor"]), []string{"store"}) {
t.Fatalf("a move: %+v, %+v, %v", p, laid, findings)
}
if _, err := delivery.EncodePlacementProposal(p); err != nil {
if _, err := plc.EncodeProposal(p); err != nil {
t.Fatalf("the proposal cannot be written: %v", err)
}
for name, c := range map[string]struct {
in ProposeIn
want string
}{
"a module not held": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "laptop", Module: "ghost"}), "the indexer holds no module ghost"},
"a node with no record": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "stranger", Module: "web"}), "stranger is a node with no record"},
"nothing changes": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "anchor", Module: "web"}), "leave every placement as it is"},
"a module not held": {in(mma.ProposalChange{Op: plc.OpPlace, Node: "laptop", Module: "ghost"}), "the indexer holds no module ghost"},
"a node with no record": {in(mma.ProposalChange{Op: plc.OpPlace, Node: "stranger", Module: "web"}), "stranger is a node with no record"},
"nothing changes": {in(mma.ProposalChange{Op: plc.OpPlace, Node: "anchor", Module: "web"}), "leave every placement as it is"},
"no why": {func() ProposeIn { i := in(move...); i.Why = " "; return i }(), "says why"},
"no caller": {func() ProposeIn { i := in(move...); i.By = ""; return i }(), "names no caller"},
"a seed by hand": {in(delivery.PlacementProposalChange{Op: delivery.OpSeed, Node: "laptop", Whole: &laptop}), "gone with adopt"},
"a seed by hand": {in(mma.ProposalChange{Op: plc.OpSeed, Node: "laptop", Whole: &laptop}), "gone with adopt"},
// 1126: the controller ended by its unassignment; placed again it would be a second writer beside the parts that
// act without its lease.
"the controller placed again": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "laptop", Module: "mesh-controller"}), "ended by its unassignment"},
"the controller seeded whole": {in(delivery.PlacementProposalChange{Op: delivery.OpSeed, Node: "anchor", Whole: &delivery.Placement{V: delivery.Version, Node: "anchor",
Decided: anchor.Decided, Modules: []delivery.PlacedModule{{Module: "mesh-controller"}}}}), "two writers (issue 1126)"},
"a pin of nobody placed": {in(delivery.PlacementProposalChange{Op: delivery.OpPin, Node: "laptop", Provision: "db", Provider: &delivery.Provider{Node: "laptop", Module: "store"}}), "not placed there"},
"the controller placed again": {in(mma.ProposalChange{Op: plc.OpPlace, Node: "laptop", Module: "mesh-controller"}), "ended by its unassignment"},
"the controller seeded whole": {in(mma.ProposalChange{Op: plc.OpSeed, Node: "anchor", Whole: &mma.Placement{V: delivery.Version, Node: "anchor",
Decided: anchor.Decided, Modules: []mma.PlacedModule{{Module: "mesh-controller"}}}}), "two writers (issue 1126)"},
"a pin of nobody placed": {in(mma.ProposalChange{Op: plc.OpPin, Node: "laptop", Provision: "db", Provider: &mma.Provider{Node: "laptop", Module: "store"}}), "not placed there"},
} {
if _, _, f := Propose(c.in); !slices.ContainsFunc(f, func(s string) bool { return strings.Contains(s, c.want) }) {
t.Errorf("%s: findings %v, want one naming %q", name, f, c.want)
@@ -56,13 +60,13 @@ func TestThePlacementSeatDecides(t *testing.T) {
}
// Every kind of change is taken: the placement is the one source, and the controller's verbs limit nothing (ADR 0567 §8).
for _, c := range []delivery.PlacementProposalChange{
{Op: delivery.OpConfig, Node: "anchor", Module: "web", Values: map[string]any{"puid": 1}},
{Op: delivery.OpPlace, Node: "laptop", Module: "web", Ports: map[string]int{"80": 8080}, Bindings: map[string]delivery.Provider{"db": {Node: "anchor", Module: "store"}}},
{Op: delivery.OpConfig, Node: "anchor", Module: "web", Layer: map[string]any{"places": []any{}}},
{Op: delivery.OpConfig, Node: "anchor", Module: "web", MeshWide: map[string]any{"accesses": []any{}}},
{Op: delivery.OpDecide, Node: "laptop", Field: "site", Value: json.RawMessage(`"attic"`)},
{Op: delivery.OpHold, Node: "anchor", Module: "store", Claim: "node-shell", Scope: "node"},
for _, c := range []mma.ProposalChange{
{Op: plc.OpConfig, Node: "anchor", Module: "web", Values: map[string]any{"puid": 1}},
{Op: plc.OpPlace, Node: "laptop", Module: "web", Ports: map[string]int64{"80": 8080}, Bindings: map[string]mma.Provider{"db": {Node: "anchor", Module: "store"}}},
{Op: plc.OpConfig, Node: "anchor", Module: "web", Layer: map[string]any{"places": []any{}}},
{Op: plc.OpConfig, Node: "anchor", Module: "web", MeshWide: map[string]any{"accesses": []any{}}},
{Op: plc.OpDecide, Node: "laptop", Field: "site", Value: json.RawMessage(`"attic"`)},
{Op: plc.OpHold, Node: "anchor", Module: "store", Claim: "node-shell", Scope: "node"},
} {
if _, _, f := Propose(in(c)); len(f) != 0 {
t.Errorf("%s is refused: %v", c.Op, f)
@@ -70,8 +74,8 @@ func TestThePlacementSeatDecides(t *testing.T) {
}
// A key moved while the warrant wrote: what is left to write is the other nodes' changes, to be laid and asked again.
if rest, ok := Rest(p, map[string]*delivery.Placement{"anchor": laid["anchor"]}); !ok || len(rest.Changes) != 1 || rest.Changes[0].Node != "laptop" ||
len(rest.Base) != 1 || rest.State != delivery.ProposalAnswering || rest.Must != nil {
if rest, ok := Rest(p, map[string]*mma.Placement{"anchor": laid["anchor"]}); !ok || len(rest.Changes) != 1 || rest.Changes[0].Node != "laptop" ||
len(rest.Base) != 1 || rest.State != plc.ProposalAnswering || rest.Must != nil {
t.Errorf("the rest = %+v, %v", rest, ok)
}
if _, ok := Rest(p, laid); ok {
@@ -79,10 +83,10 @@ func TestThePlacementSeatDecides(t *testing.T) {
}
// The seat says it waits while one of its states is not bound, naming it, and clears once all are.
for _, unbound := range [][]string{{delivery.PlacementPulsesBucket}, nil} {
for _, unbound := range [][]string{{plc.PulsesBucket}, nil} {
c := SeatWaitsCondition(unbound)
if _, err := occ.EncodeCondition(c); err != nil || (len(unbound) > 0) != (c.State == occ.StateRaised) ||
(len(unbound) > 0 && !strings.Contains(c.Explanation, "the seat waits for its states: "+delivery.PlacementPulsesBucket)) {
(len(unbound) > 0 && !strings.Contains(c.Explanation, "the seat waits for its states: "+plc.PulsesBucket)) {
t.Errorf("waiting for %v: %+v, %v", unbound, c, err)
}
}
@@ -96,7 +100,7 @@ func TestThePlacementSeatDecides(t *testing.T) {
t.Fatalf("the ask = %+v, %v", a, err)
}
q := p
q.Changes = append(slices.Clone(p.Changes), delivery.PlacementProposalChange{Op: delivery.OpUnpin, Node: "laptop", Provision: "x"})
q.Changes = append(slices.Clone(p.Changes), mma.ProposalChange{Op: plc.OpUnpin, Node: "laptop", Provision: "x"})
if occ.Performs(mustAsk(t, a).Options[0], PlacementAct(q)) == nil {
t.Error("the ask's yes binds changes it was not asked about")
}
@@ -15,11 +15,14 @@ import (
"time"
nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
mma "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-sdk/go/delivery"
plc "mesh-delivery-assigner/internal/placement"
)
// AnswerBound is how long the holder waits for a node's answer after its candidate was written: the time to live of the
// node's pulse key (delivery.PulseAnswerKey), whose expiry names it as giving no answer, never passed. The same answer
// node's pulse key (placement.PulseAnswerKey), whose expiry names it as giving no answer, never passed. The same answer
// bound a check gives a node (ADR 0593 §8, §7: 60 seconds, nec.AnswerBound). A variable so a test on the bus
// can shorten it.
var AnswerBound = nec.AnswerBound
@@ -30,7 +33,7 @@ var AnswerBound = nec.AnswerBound
// node's set holds the modules the proposal adds, removes or whose placed entry differs on that node; a node with no
// key now gets an empty set (§7: the seed is answered as the same). What a node's compositions change beyond its own
// placement it says itself in its answer, marked in the brief as not computed.
func Intended(current map[string]delivery.Placement, laid map[string]*delivery.Placement, nodes []string) (intended map[string]map[string]bool, must []string) {
func Intended(current map[string]mma.Placement, laid map[string]*mma.Placement, nodes []string) (intended map[string]map[string]bool, must []string) {
intended = map[string]map[string]bool{}
all := map[string]bool{}
for _, n := range nodes {
@@ -48,11 +51,11 @@ func Intended(current map[string]delivery.Placement, laid map[string]*delivery.P
if !changesKey || !placed {
continue // untouched, or new to placement: every answer must be the same
}
var afterModules []delivery.PlacedModule
var afterModules []mma.PlacedModule
if after != nil {
afterModules = after.Modules
}
entries := map[string]delivery.PlacedModule{}
entries := map[string]mma.PlacedModule{}
for _, m := range before.Modules {
entries[m.Module] = m
}
@@ -80,10 +83,10 @@ func Intended(current map[string]delivery.Placement, laid map[string]*delivery.P
// on the bus) is named as giving no answer and the proposal decided on the answers it has (ADR 0593 §8).
// A difference fails nothing: one Intended computed is listed under Intended, one it did not under Unintended, both
// shown in the ask's brief. The seed alone fails on any difference.
func JudgeAnswers(p delivery.PlacementProposal, intended map[string]map[string]bool, answers map[string]*delivery.CandidateAnswer,
revisions map[string]uint64, online map[string]bool, pulsing map[string]bool, now time.Time) delivery.PlacementVerdict {
v := delivery.PlacementVerdict{At: now.UTC(), Intended: map[string][]string{}, Unintended: map[string][]string{}, Carried: map[string][]string{},
Answers: map[string]delivery.CandidateAnswer{}}
func JudgeAnswers(p mma.Proposal, intended map[string]map[string]bool, answers map[string]*delivery.CandidateAnswer,
revisions map[string]uint64, online map[string]bool, pulsing map[string]bool, now time.Time) mma.Verdict {
v := mma.Verdict{At: now.UTC(), Intended: map[string][]string{}, Unintended: map[string][]string{}, Carried: map[string][]string{},
Answers: map[string]map[string]any{}}
for node, set := range intended {
for m := range set {
v.Intended[node] = append(v.Intended[node], m)
@@ -92,7 +95,7 @@ func JudgeAnswers(p delivery.PlacementProposal, intended map[string]map[string]b
}
must := p.Must
if len(must) == 0 {
must = p.Nodes()
must = plc.Nodes(p)
}
for _, node := range must {
a := answers[node]
@@ -100,7 +103,7 @@ func JudgeAnswers(p delivery.PlacementProposal, intended map[string]map[string]b
v.Silent = append(v.Silent, node)
continue
}
v.Answers[node] = *a
v.Answers[node] = answerBody(*a)
if a.Refused != "" {
v.Fails = append(v.Fails, fmt.Sprintf("%s cannot compose the proposal: %s", node, a.Refused))
continue
@@ -167,7 +170,7 @@ func JudgeAnswers(p delivery.PlacementProposal, intended map[string]map[string]b
case len(v.Fails) > 0:
v.State = delivery.VerdictFail
case len(waiting) > 0:
v.State = delivery.VerdictWaits
v.State = plc.VerdictWaits
default:
v.State = delivery.VerdictPass
}
@@ -176,7 +179,7 @@ func JudgeAnswers(p delivery.PlacementProposal, intended map[string]map[string]b
// Brief is the verdict as the ask's brief shows it, by node (§5): what the proposal changes and what each node answered
// beside it, marked, and what stays held as the node holds it today, with the proposal's why.
func Brief(p delivery.PlacementProposal, v delivery.PlacementVerdict) string {
func Brief(p mma.Proposal, v mma.Verdict) string {
var b strings.Builder
fmt.Fprintf(&b, "%s (proposed by %s)", p.Why, p.By)
nodes := map[string]bool{}
@@ -186,11 +189,11 @@ func Brief(p delivery.PlacementProposal, v delivery.PlacementVerdict) string {
for n := range v.Unintended {
nodes[n] = true
}
for _, n := range p.Nodes() {
for _, n := range plc.Nodes(p) {
nodes[n] = true
}
for n, a := range v.Answers {
if len(a.Held) > 0 {
if len(answerOf(a).Held) > 0 {
nodes[n] = true
}
}
@@ -215,7 +218,7 @@ func Brief(p delivery.PlacementProposal, v delivery.PlacementVerdict) string {
if m := v.Carried[n]; len(m) > 0 {
fmt.Fprintf(&b, "; the seed carries: %s", strings.Join(m, "; "))
}
if held := v.Answers[n].Held; len(held) > 0 {
if held := answerOf(v.Answers[n]).Held; len(held) > 0 {
var names []string
for _, h := range held {
today := h.Today
@@ -330,3 +333,20 @@ func fieldName(running, composed, path string) string {
}
return path
}
// answerBody is a node's answer as the verdict carries it: the seat's contract keeps a verdict's answers as bodies, since
// the answer's layout is the node-engine's (ADR 0593 §7).
func answerBody(a delivery.CandidateAnswer) map[string]any {
raw, _ := json.Marshal(a)
var out map[string]any
_ = json.Unmarshal(raw, &out)
return out
}
// answerOf reads a verdict's answer body back; a body that does not fit is an answer with nothing in it.
func answerOf(body map[string]any) delivery.CandidateAnswer {
raw, _ := json.Marshal(body)
var out delivery.CandidateAnswer
_ = json.Unmarshal(raw, &out)
return out
}
@@ -6,17 +6,20 @@ import (
"testing"
"time"
mma "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-sdk/go/delivery"
plc "mesh-delivery-assigner/internal/placement"
)
// ADR 0567 §3, §4 (rows moved from the assigner's check of a pull request's placement, ADR 0559 §5): what a proposal
// changes by node, and the verdict of the answers: differences shown and failing nothing, a refusal and a silent node
// failing, a later answer judging again, the seed failing on any difference.
func TestJudge(t *testing.T) {
current := map[string]delivery.Placement{"anchor": {Node: "anchor", Modules: []delivery.PlacedModule{{Module: "mail"}}}, "server": {Node: "server"}}
laid := map[string]*delivery.Placement{
"anchor": {Node: "anchor", Modules: []delivery.PlacedModule{{Module: "mail", Ports: map[string]int{"25": 2525}}, {Module: "web"}}},
"laptop": {Node: "laptop", Modules: []delivery.PlacedModule{{Module: "web"}}}}
current := map[string]mma.Placement{"anchor": {Node: "anchor", Modules: []mma.PlacedModule{{Module: "mail"}}}, "server": {Node: "server"}}
laid := map[string]*mma.Placement{
"anchor": {Node: "anchor", Modules: []mma.PlacedModule{{Module: "mail", Ports: map[string]int64{"25": 2525}}, {Module: "web"}}},
"laptop": {Node: "laptop", Modules: []mma.PlacedModule{{Module: "web"}}}}
// ADR 0593 §8: every node answers; what the placement changes is read from the placement alone.
got, must := Intended(current, laid, []string{"anchor", "laptop", "server"})
if !got["anchor"]["mail"] || !got["anchor"]["web"] || len(got["laptop"]) != 0 || len(got["server"]) != 0 ||
@@ -25,7 +28,7 @@ func TestJudge(t *testing.T) {
}
at := time.Date(2026, 10, 27, 2, 0, 0, 0, time.UTC)
p := delivery.PlacementProposal{ID: "p-one", By: "op", Why: "move web", Written: at, Base: map[string]uint64{"anchor": 3, "laptop": 0},
p := mma.Proposal{ID: "p-one", By: "op", Why: "move web", Written: &at, Base: map[string]int64{"anchor": 3, "laptop": 0},
Must: []string{"anchor", "laptop"}}
revisions := map[string]uint64{"anchor": 7, "laptop": 8}
intended := map[string]map[string]bool{"anchor": {"web": true}}
@@ -56,7 +59,7 @@ func TestJudge(t *testing.T) {
Composed: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/hosts\":\"hostname\",\"/etc/resolv.conf\":\"systemd-resolved (node-resolver)\"}","MESH_TOOL_MODULES":"… (cut at 8192 bytes)`}}}}}}, nil, delivery.VerdictPass, "", ""},
{"a refusal", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8, Verdict: delivery.CandidateRefused,
Refusals: []delivery.ModuleRefused{{Module: "web", Why: "no own secret"}}}}, nil, delivery.VerdictFail, "laptop refuses", ""},
{"a node not answering, within its bound", false, map[string]*delivery.CandidateAnswer{"anchor": same(7)}, map[string]bool{"laptop": true}, delivery.VerdictWaits, "laptop (offline)", ""},
{"a node not answering, within its bound", false, map[string]*delivery.CandidateAnswer{"anchor": same(7)}, map[string]bool{"laptop": true}, plc.VerdictWaits, "laptop (offline)", ""},
{"an answer of an earlier revision, past its bound, is no answer and decided without it", false, map[string]*delivery.CandidateAnswer{"anchor": same(6), "laptop": same(8)}, nil, delivery.VerdictPass, "no answer from anchor", ""},
{"a module held as today passes, shown in the brief", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8,
Verdict: delivery.CandidateSame, Held: []delivery.ModuleRefused{{Module: "spiegel", Why: "a required value is unset"}}}}, nil, delivery.VerdictPass, "", ""},
@@ -0,0 +1,96 @@
package placement
import (
"encoding/json"
"fmt"
"strings"
client "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient"
seat "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// Placement candidates (novox/hq ADR 0567 §3): for each node a proposal must hear from, the holder writes one key
// `<proposal>.<node>` in its state `candidates`: the node's placement with the proposal laid over it for a node whose key
// it changes, or the node's current key for one whose composition it changes only through another node (Reached). The
// layouts are the seat's contract's (CandidatesByProposalNodeValue); what refuses a candidate is here.
// CandidateKey is the key of one node's candidate in one proposal: `<proposal>.<node>`, so a node watches `*.<node>` and a
// proposal's keys are `<proposal>.*`.
func CandidateKey(proposal, node string) (string, error) {
if err := client.CheckProposalID(proposal); err != nil {
return "", err
}
if err := keys.CheckName("node", node); err != nil {
return "", err
}
return seat.CandidatesByProposalNodeKey(proposal, node), nil
}
// ParseCandidateKey is the inverse of CandidateKey; it refuses any other key, naming it.
func ParseCandidateKey(key string) (proposal, node string, err error) {
proposal, node, ok := strings.Cut(key, ".")
if !ok || keys.CheckName("node", node) != nil || client.CheckProposalID(proposal) != nil {
return "", "", fmt.Errorf("placement: %q is not a placement candidate key, <proposal>.<node>", key)
}
return proposal, node, nil
}
func checkCandidate(c seat.CandidatesByProposalNodeValue) error {
if err := client.CheckProposalID(c.Proposal); err != nil {
return err
}
if c.Placement != nil {
return client.CheckPlacement(*c.Placement)
}
return nil
}
// EncodeCandidate is c as JSON at the keys' Version; it refuses what DecodeCandidate refuses.
func EncodeCandidate(c seat.CandidatesByProposalNodeValue) ([]byte, error) {
if err := checkCandidate(c); err != nil {
return nil, err
}
c.V = keys.Version
if c.Placement != nil {
p := *c.Placement
p.V = keys.Version
c.Placement = &p
}
return json.Marshal(c)
}
// DecodeCandidate reads a candidate; it refuses an unknown version, a proposal id that is not one, and a placement the
// client refuses.
func DecodeCandidate(data []byte) (seat.CandidatesByProposalNodeValue, error) {
if err := keys.CheckVersion(data); err != nil {
return seat.CandidatesByProposalNodeValue{}, err
}
c, err := seat.DecodeCandidatesByProposalNodeValue(data)
if err != nil {
return seat.CandidatesByProposalNodeValue{}, fmt.Errorf("placement: a candidate does not fit its shape: %w", err)
}
if err := checkCandidate(c); err != nil {
return seat.CandidatesByProposalNodeValue{}, err
}
return c, nil
}
// PulseAnswerKey is the key of the bound on one node's answer to a proposal: `answer.<proposal>.<node>`.
func PulseAnswerKey(proposal, node string) (string, error) {
if err := client.CheckProposalID(proposal); err != nil {
return "", err
}
if err := keys.CheckName("node", node); err != nil {
return "", err
}
return seat.PulsesByAnswerProposalNodeKey(proposal, node), nil
}
// PulseAskKey is the key of the bound on a proposal's raised ask: `ask.<proposal>`.
func PulseAskKey(proposal string) (string, error) {
if err := client.CheckProposalID(proposal); err != nil {
return "", err
}
return seat.PulsesByAskProposalKey(proposal), nil
}
@@ -0,0 +1,233 @@
// Package placement is the assigner's own check of a placement and of a proposal that changes it (novox/hq ADR 0567 §2,
// ADR 0634 §1 on where a seat's code goes): what the holder of the seat mesh-module-assigner decides before it writes
// anything. The layouts it works on are the seat's contract's (package meshmoduleassigner); what every reader of the
// placement shares (the keys assembled into a node's placement, the node key's digest, who holds a seat) is the seat's
// client's (package meshmoduleassignerclient). Nothing here is read by another module.
package placement
import (
"encoding/json"
"fmt"
"sort"
"strings"
client "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient"
seat "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// KeptApart are the modules never placed on one node, each row naming the record that asks it (novox/hq ADR 0564 clause 6
// and Limits 5; issue 1952 S8): no manifest word says it, so the check below holds the table. mesh-watcher hears the
// self-check's `checked` from a second machine (ADR 0227 §6), which on the self-check's own node would fall silent with
// it.
var KeptApart = []struct{ A, B, Record string }{
{A: "mesh-watcher", B: "mesh-self-check", Record: "ADR 0227 §6, ADR 0564 clause 6"},
}
// CheckPlacements is the holder's check of a proposal laid over every placement (ADR 0567 §2), over the placements by
// node, the modules that are known and the nodes with a record. It returns every refusal, in node order, never only the
// first: a placement naming a node not enrolled or a module that is not known (held by the indexer, or provided by the
// mesh itself: the caller's `known` says both); two modules of one node on the same host port; a pin, binding or holding
// naming a provider that is not placed where it says; a seat of scope mesh held twice, unless the seat is replicated
// (catalogue.Seat's Replicated, ADR 0223), which is held once per node; two nodes with one address on the private
// network; more than one hub; two modules KeptApart on one node. A host port a manifest declares without a number and a
// requirement left unbound are the composition's to find (its ForeseenPorts and Unbound).
func CheckPlacements(placements map[string]seat.Placement, known func(module string) bool, enrolled func(node string) bool) []string {
var refusals []string
nodes := sortedKeys(placements)
placed := func(p seat.Provider) bool {
other, ok := placements[p.Node]
if !ok {
return false
}
for _, m := range other.Modules {
if m.Module == p.Module {
return true
}
}
return false
}
meshSeats := map[string]string{}
addresses := map[string]string{}
var hubs []string
for _, node := range nodes {
p := placements[node]
if p.Node != node {
refusals = append(refusals, fmt.Sprintf("the placement of %s names the node %q", node, p.Node))
}
if a := strings.TrimSpace(p.Decided.Address); a != "" {
if other, twice := addresses[a]; twice {
refusals = append(refusals, fmt.Sprintf("%s and %s both have the address %s on the private network", other, node, a))
}
addresses[a] = node
}
if p.Decided.Hub {
hubs = append(hubs, node)
}
if !enrolled(node) {
refusals = append(refusals, fmt.Sprintf("%s is a node with no record", node))
}
placedHere := map[string]bool{}
for _, m := range p.Modules {
placedHere[m.Module] = true
}
for _, apart := range KeptApart {
if placedHere[apart.A] && placedHere[apart.B] {
refusals = append(refusals, fmt.Sprintf("%s: %s and %s are never placed on one node (%s)", node, apart.A, apart.B, apart.Record))
}
}
hostPorts := map[int64]string{}
for _, m := range p.Modules {
if !known(m.Module) {
refusals = append(refusals, fmt.Sprintf("%s: the indexer holds no module %s", node, m.Module))
}
for _, port := range sortedKeys(m.Ports) {
at := m.Ports[port]
if other, clash := hostPorts[at]; clash && other != m.Module {
refusals = append(refusals, fmt.Sprintf("%s: %s and %s are both published on host port %d", node, other, m.Module, at))
}
hostPorts[at] = m.Module
}
for _, provision := range sortedKeys(m.Bindings) {
if to := m.Bindings[provision]; !placed(to) {
refusals = append(refusals, fmt.Sprintf("%s: %s's %s is bound to %s/%s, which is not placed there", node, m.Module, provision, to.Node, to.Module))
}
}
}
for _, provision := range sortedKeys(p.Pins) {
if to := p.Pins[provision]; !placed(to) {
refusals = append(refusals, fmt.Sprintf("%s: the pin of %s names %s/%s, which is not placed there", node, provision, to.Node, to.Module))
}
}
for _, h := range p.Holds {
if h.Scope != "mesh" {
continue
}
name := h.Claim + "/" + h.Kind
if s, known := catalogue.SeatNamed(h.Claim); known && s.Replicated {
// A replicated mesh seat (ADR 0223) is held on several nodes at once, each answering the same thing;
// it is still held once on each node.
name += "/" + node
}
if other, twice := meshSeats[name]; twice {
refusals = append(refusals, fmt.Sprintf("the seat %s, held once for the mesh, is held on %s and %s", h.Claim, other, node))
}
meshSeats[name] = node
}
}
if len(hubs) > 1 {
refusals = append(refusals, fmt.Sprintf("the private network has one hub, and %s are each placed as it", strings.Join(hubs, ", ")))
}
return refusals
}
// Change is what `placement-changed` says of one node (ADR 0567 §6): the node, the revision of its key before (0: it had
// none) and after (0: the key was deleted), and its modules added, removed or changed against the placement before.
type Change struct {
Node string `json:"node"`
Before uint64 `json:"before"`
After uint64 `json:"after"`
Added []string `json:"added,omitempty"`
Removed []string `json:"removed,omitempty"`
Changed []string `json:"changed,omitempty"`
}
// Body is the change as the seat's event carries it, one entry of its `nodes`.
func (c Change) Body() map[string]any {
raw, _ := json.Marshal(c)
var out map[string]any
_ = json.Unmarshal(raw, &out)
return out
}
// ChangeOf is what changed from before to after on one node; before is the zero Placement for a node new to placement.
// A change of the node's pins or holdings counts as a change of every module placed on it both times, since the
// composition of each may read them.
func ChangeOf(before, after seat.Placement) Change {
c := Change{Node: after.Node}
if c.Node == "" {
c.Node = before.Node
}
was := map[string]seat.PlacedModule{}
for _, m := range before.Modules {
was[m.Module] = m
}
nodeWide := !jsonEqual(before.Pins, after.Pins) || !jsonEqual(before.Holds, after.Holds)
now := map[string]bool{}
for _, m := range after.Modules {
now[m.Module] = true
old, ok := was[m.Module]
switch {
case !ok:
c.Added = append(c.Added, m.Module)
case nodeWide || !jsonEqual(old, m):
c.Changed = append(c.Changed, m.Module)
}
}
for _, m := range before.Modules {
if !now[m.Module] {
c.Removed = append(c.Removed, m.Module)
}
}
sort.Strings(c.Added)
sort.Strings(c.Removed)
sort.Strings(c.Changed)
return c
}
// SortChanges orders a placement-changed event's nodes by node.
func SortChanges(c []Change) {
sort.Slice(c, func(i, j int) bool { return c[i].Node < c[j].Node })
}
func jsonEqual(a, b any) bool {
ja, _ := json.Marshal(a)
jb, _ := json.Marshal(b)
return string(ja) == string(jb)
}
// SeatHoldersOf is the state composed from every node's placement (ADR 0567 §1): each holding by the seat it claims,
// keyed as the client's SeatHoldersKey says, with the module holding it and its node. A claim naming a seat's former name
// is keyed by the seat's name (ADR 0122); one naming no seat is kept, marked Outside. Deterministic, so a writer writes
// only a key that differs.
func SeatHoldersOf(placements map[string]seat.Placement) map[string]seat.SeatHolders {
out := map[string]seat.SeatHolders{}
for _, n := range sortedKeys(placements) {
for _, h := range placements[n].Holds {
node := ""
if h.Scope == "node" {
node = n
}
s, known := catalogue.SeatNamed(h.Claim)
name := h.Claim
if known {
name = s.Name
}
key, err := client.SeatHoldersKey(name, node)
if err != nil {
continue
}
sh := out[key]
sh.V, sh.Seat, sh.Scope, sh.Node, sh.Outside = keys.Version, name, h.Scope, node, !known
sh.Holders = append(sh.Holders, seat.SeatHoldersHoldersItem{Module: h.Module, Node: n})
out[key] = sh
}
}
for k, sh := range out {
sort.Slice(sh.Holders, func(i, j int) bool {
return sh.Holders[i].Node+"/"+sh.Holders[i].Module < sh.Holders[j].Node+"/"+sh.Holders[j].Module
})
out[k] = sh
}
return out
}
func sortedKeys[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
@@ -0,0 +1,215 @@
package placement
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
seat "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// The digests here are of values a proposal's ask and a seed were bound to before this package: the fields are written in
// the order the first writer wrote them (the generated contract types order theirs by name), so a digest an earlier holder
// bound an ask to is the one this computes. Golden values in the tests hold it.
type providerForm struct {
Node string `json:"node"`
Module string `json:"module"`
}
type placedForm struct {
Module string `json:"module"`
Ports map[string]int64 `json:"ports,omitempty"`
Bindings map[string]providerForm `json:"bindings,omitempty"`
MeshWide map[string]any `json:"mesh-wide,omitempty"`
HasMesh bool `json:"has-mesh-wide,omitempty"`
Layer map[string]any `json:"layer,omitempty"`
HasLayer bool `json:"has-layer,omitempty"`
MeshValues map[string]any `json:"mesh-wide-values,omitempty"`
Values map[string]any `json:"values,omitempty"`
}
type holdingForm struct {
Claim string `json:"claim"`
Scope string `json:"scope,omitempty"`
Module string `json:"module"`
Site string `json:"site,omitempty"`
Kind string `json:"kind,omitempty"`
}
type decidedForm struct {
Endpoint string `json:"endpoint,omitempty"`
Site string `json:"site,omitempty"`
Hub bool `json:"hub,omitempty"`
Address string `json:"address,omitempty"`
PublicDomain string `json:"public-domain,omitempty"`
Account string `json:"account,omitempty"`
AccountHome string `json:"account-home,omitempty"`
AgentAccount string `json:"agent-account,omitempty"`
AgentHome string `json:"agent-account-home,omitempty"`
Adopted bool `json:"adopted,omitempty"`
Taken []string `json:"taken,omitempty"`
Tunnel *tunnelForm `json:"tunnel,omitempty"`
PeerNames map[string]string `json:"peer-names,omitempty"`
}
type tunnelForm struct {
Interface string `json:"interface"`
Unit string `json:"unit,omitempty"`
Config string `json:"config,omitempty"`
Port int64 `json:"port,omitempty"`
MTU int64 `json:"mtu,omitempty"`
Address string `json:"address,omitempty"`
Range string `json:"range,omitempty"`
PublicKey string `json:"public-key,omitempty"`
Peers []tunnelPeerForm `json:"peers,omitempty"`
}
type tunnelPeerForm struct {
PublicKey string `json:"public-key"`
Address string `json:"address,omitempty"`
}
type placementForm struct {
V int `json:"v"`
Node string `json:"node"`
Revision string `json:"revision,omitempty"`
Decided decidedForm `json:"decided"`
Modules []placedForm `json:"modules"`
Pins map[string]providerForm `json:"pins,omitempty"`
Holds []holdingForm `json:"holds,omitempty"`
}
type joiningForm struct {
Identity string `json:"identity"`
Overlay string `json:"overlay,omitempty"`
Sealing string `json:"sealing"`
Serving string `json:"serving,omitempty"`
}
type changeForm struct {
Op string `json:"op"`
Node string `json:"node"`
Module string `json:"module,omitempty"`
Ports map[string]int64 `json:"ports,omitempty"`
Bindings map[string]providerForm `json:"bindings,omitempty"`
Provision string `json:"provision,omitempty"`
Provider *providerForm `json:"provider,omitempty"`
Claim string `json:"claim,omitempty"`
Scope string `json:"scope,omitempty"`
Kind string `json:"kind,omitempty"`
Site string `json:"site,omitempty"`
Field string `json:"field,omitempty"`
Value json.RawMessage `json:"value,omitempty"`
MeshWide map[string]any `json:"mesh-wide,omitempty"`
Layer map[string]any `json:"layer,omitempty"`
MeshValues map[string]any `json:"mesh-wide-values,omitempty"`
Values map[string]any `json:"values,omitempty"`
Whole *placementForm `json:"whole,omitempty"`
Decided *decidedForm `json:"decided,omitempty"`
Keys *joiningForm `json:"keys,omitempty"`
}
func providerOf(p seat.Provider) providerForm { return providerForm{Node: p.Node, Module: p.Module} }
func providersOf(m map[string]seat.Provider) map[string]providerForm {
if len(m) == 0 {
return nil
}
out := make(map[string]providerForm, len(m))
for k, v := range m {
out[k] = providerOf(v)
}
return out
}
func tunnelOf(t *seat.FoundTunnel) *tunnelForm {
if t == nil {
return nil
}
out := &tunnelForm{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.Mtu, Address: t.Address, Range: t.Range, PublicKey: t.PublicKey}
for _, p := range t.Peers {
out.Peers = append(out.Peers, tunnelPeerForm{PublicKey: p.PublicKey, Address: p.Address})
}
return out
}
func decidedOf(d seat.NodeDecided) decidedForm {
return decidedForm{Endpoint: d.Endpoint, Site: d.Site, Hub: d.Hub, Address: d.Address, PublicDomain: d.PublicDomain,
Account: d.Account, AccountHome: d.AccountHome, AgentAccount: d.AgentAccount, AgentHome: d.AgentAccountHome,
Adopted: d.Adopted, Taken: d.Taken, Tunnel: tunnelOf(d.Tunnel), PeerNames: d.PeerNames}
}
func placementOf(p seat.Placement) placementForm {
out := placementForm{V: int(p.V), Node: p.Node, Revision: p.Revision, Decided: decidedOf(p.Decided),
Pins: providersOf(p.Pins)}
if p.Modules != nil {
out.Modules = []placedForm{}
}
for _, m := range p.Modules {
out.Modules = append(out.Modules, placedForm{Module: m.Module, Ports: m.Ports, Bindings: providersOf(m.Bindings),
MeshWide: m.MeshWide, HasMesh: m.HasMeshWide, Layer: m.Layer, HasLayer: m.HasLayer, MeshValues: m.MeshWideValues, Values: m.Values})
}
for _, h := range p.Holds {
out.Holds = append(out.Holds, holdingForm{Claim: h.Claim, Scope: h.Scope, Module: h.Module, Site: h.Site, Kind: h.Kind})
}
return out
}
func changeOf(c seat.ProposalChange) changeForm {
out := changeForm{Op: c.Op, Node: c.Node, Module: c.Module, Ports: c.Ports, Bindings: providersOf(c.Bindings), Provision: c.Provision,
Claim: c.Claim, Scope: c.Scope, Kind: c.Kind, Site: c.Site, Field: c.Field, MeshWide: c.MeshWide, Layer: c.Layer,
MeshValues: c.MeshWideValues, Values: c.Values}
if c.Provider != nil {
p := providerOf(*c.Provider)
out.Provider = &p
}
if c.Value != nil {
out.Value, _ = json.Marshal(c.Value)
}
if c.Whole != nil {
w := placementOf(*c.Whole)
out.Whole = &w
}
if c.Decided != nil {
d := decidedOf(*c.Decided)
out.Decided = &d
}
if c.Keys != nil {
out.Keys = &joiningForm{Identity: c.Keys.Identity, Overlay: c.Keys.Overlay, Sealing: c.Keys.Sealing, Serving: c.Keys.Serving}
}
return out
}
// ExportDigest is the digest of an export's answer, every node's placement, as the seed proposes it: the same
// placements give the same digest, whatever order they came in.
func ExportDigest(placements map[string]seat.Placement) string {
h := sha256.New()
for _, node := range sortedKeys(placements) {
p := placements[node]
p.V = keys.Version // the export was made with the placement encoded at the keys' version
raw, _ := json.Marshal(placementOf(p))
fmt.Fprintf(h, "%s\n%s\n", node, raw)
}
return "sha256:" + hex.EncodeToString(h.Sum(nil))
}
// ChangesDigest is the digest of a proposal's changes, which its authorising ask is bound to (§5).
func ChangesDigest(p seat.Proposal) string {
var changes []changeForm
if p.Changes != nil {
changes = make([]changeForm, 0, len(p.Changes))
}
for _, c := range p.Changes {
changes = append(changes, changeOf(c))
}
raw, _ := json.Marshal(struct {
ID string `json:"id"`
Changes []changeForm `json:"changes"`
Base map[string]int64 `json:"base"`
}{p.ID, changes, p.Base})
sum := sha256.Sum256(raw)
return "sha256:" + hex.EncodeToString(sum[:])
}
@@ -0,0 +1,15 @@
package placement
import (
"os"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest"
)
// TestMain gives catalogue the seat list a host reads (ADR 0636 §1): none is compiled into mesh-sdk.
func TestMain(m *testing.M) {
catalogue.UseSeats(catalogue.SeatsFromDefinitions(seatlisttest.Definitions()))
os.Exit(m.Run())
}
@@ -0,0 +1,322 @@
package placement
import (
"encoding/json"
"errors"
"reflect"
"slices"
"strings"
"testing"
"time"
client "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient"
seat "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// ADR 0567 §2, §4: what a change says, the holder's check of every placement, a proposal's changes laid over the keys,
// its digests (held to the values the earlier holder's code made, in the field order it wrote), its refusals, and who holds
// which seat.
func TestPlacement(t *testing.T) {
g14 := seat.Placement{Node: "g14", Revision: "abc",
Decided: seat.NodeDecided{Endpoint: "g14.example:51820", Site: "home", Address: "10.77.0.2", Account: "op", Taken: []string{"zsh"},
Tunnel: &seat.FoundTunnel{Interface: "wg0", Config: "c", Port: 51820, Mtu: 1420, PublicKey: "K", Peers: []seat.FoundTunnelPeer{{PublicKey: "P", Address: "10.0.0.3"}}},
PeerNames: map[string]string{"P": "pi"}},
Modules: []seat.PlacedModule{
{Module: "zsh"},
{Module: "app", Ports: map[string]int64{"8080": 18080}, Bindings: map[string]seat.Provider{"postgres": {Node: "novox", Module: "postgres"}}},
},
Pins: map[string]seat.Provider{"shell": {Node: "g14", Module: "zsh"}},
Holds: []seat.Holding{{Claim: "node-shell", Scope: "node", Module: "zsh"}},
}
novox := seat.Placement{Node: "novox", Decided: seat.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []seat.PlacedModule{{Module: "postgres", Ports: map[string]int64{"5432": 5432}}},
Holds: []seat.Holding{{Claim: "mesh-store", Scope: "mesh", Module: "postgres"}}}
current := map[string]seat.Placement{"g14": g14, "novox": novox}
const id = "p-20261028t183000-1a2b3c"
const enroller = "mesh-enrolment"
t.Run("what a change says", func(t *testing.T) {
after := g14
after.Modules = []seat.PlacedModule{{Module: "app", Ports: map[string]int64{"8080": 18081}}, {Module: "new"}}
got := ChangeOf(g14, after)
want := Change{Node: "g14", Added: []string{"new"}, Removed: []string{"zsh"}, Changed: []string{"app"}}
if !reflect.DeepEqual(got, want) {
t.Errorf("ChangeOf = %+v, want %+v", got, want)
}
pinned := g14
pinned.Pins = nil
if got := ChangeOf(g14, pinned); !reflect.DeepEqual(got.Changed, []string{"app", "zsh"}) {
t.Errorf("a pin change changes every module on the node: %+v", got)
}
if got := ChangeOf(seat.Placement{}, novox); !reflect.DeepEqual(got.Added, []string{"postgres"}) || got.Node != "novox" {
t.Errorf("a new node: %+v", got)
}
c := []Change{{Node: "novox"}, {Node: "g14", Before: 1, After: 2}}
SortChanges(c)
if c[0].Node != "g14" || c[0].Body()["after"] != float64(2) {
t.Errorf("sorted %+v, body %v", c, c[0].Body())
}
})
t.Run("the check of every placement", func(t *testing.T) {
known := func(m string) bool { return m != "ghost" }
enrolled := func(n string) bool { return n != "stranger" }
if r := CheckPlacements(current, known, enrolled); len(r) != 0 {
t.Fatalf("a sound placement is refused: %v", r)
}
// A replicated mesh seat (ADR 0223) is held on two nodes; a seat that is not replicated is refused there.
for name, refused := range map[string]bool{"mesh-dns-resolver": false, "mesh-broker": true} {
a := seat.Placement{Node: "ace", Modules: []seat.PlacedModule{{Module: "dns"}}, Holds: []seat.Holding{{Claim: name, Scope: "mesh", Module: "dns"}}}
b := a
b.Node = "novox"
r := CheckPlacements(map[string]seat.Placement{"ace": a, "novox": b}, known, enrolled)
if (len(r) != 0) != refused {
t.Errorf("%s on two nodes: refusals %v, want refused %v", name, r, refused)
}
}
clash := g14
clash.Modules = append([]seat.PlacedModule{{Module: "ghost", Ports: map[string]int64{"80": 18080}}}, g14.Modules...)
clash.Modules[2] = seat.PlacedModule{Module: "app", Ports: map[string]int64{"8080": 18080}, Bindings: map[string]seat.Provider{"postgres": {Node: "ace", Module: "postgres"}}}
clash.Pins = map[string]seat.Provider{"shell": {Node: "g14", Module: "bash"}}
// ADR 0564 clause 6, Limits 5 (issue 1952 S8): mesh-watcher and mesh-self-check are never placed on one node.
clash.Modules = append(clash.Modules, seat.PlacedModule{Module: "mesh-watcher"}, seat.PlacedModule{Module: "mesh-self-check"})
twice := novox
twice.Node = "stranger"
twice.Decided.Address = "10.77.0.2"
r := CheckPlacements(map[string]seat.Placement{"g14": clash, "novox": novox, "stranger": twice}, known, enrolled)
for _, want := range []string{"holds no module ghost", "ghost and app are both published on host port 18080",
"bound to ace/postgres", "pin of shell names g14/bash", "stranger is a node with no record",
"mesh-store, held once for the mesh, is held on novox and stranger",
"g14 and stranger both have the address 10.77.0.2", "novox, stranger are each placed as it",
"g14: mesh-watcher and mesh-self-check are never placed on one node (ADR 0227 §6, ADR 0564 clause 6)"} {
if !slices.ContainsFunc(r, func(line string) bool { return strings.Contains(line, want) }) {
t.Errorf("no refusal says %q; got %v", want, r)
}
}
})
t.Run("changes laid over the keys", func(t *testing.T) {
laid, findings := LayChanges(current, []seat.ProposalChange{
{Op: OpPlace, Node: "g14", Module: "new"},
{Op: OpTakeOff, Node: "g14", Module: "zsh"},
{Op: OpUnpin, Node: "g14", Provision: "shell"},
{Op: OpDecide, Node: "novox", Field: "site", Value: "attic"},
})
if len(findings) != 0 || len(laid) != 2 || !reflect.DeepEqual(client.Assigned(laid["g14"]), []string{"app", "new"}) ||
len(laid["g14"].Holds) != 0 || laid["g14"].Pins != nil || laid["novox"].Decided.Site != "attic" || !laid["novox"].Decided.Hub {
t.Fatalf("laid %+v, %v", laid, findings)
}
if !reflect.DeepEqual(client.Assigned(current["g14"]), []string{"zsh", "app"}) {
t.Error("laying changed the current placement")
}
if fresh, f := LayChanges(current, []seat.ProposalChange{{Op: OpPlace, Node: "pi", Module: "zsh"}}); len(f) != 0 || fresh["pi"].Node != "pi" {
t.Errorf("a node with no key: %+v, %v", fresh, f)
}
for name, c := range map[string]struct {
change seat.ProposalChange
want string
}{
"not placed": {seat.ProposalChange{Op: OpTakeOff, Node: "g14", Module: "nope"}, "not placed"},
"no such field": {seat.ProposalChange{Op: OpDecide, Node: "g14", Field: "colour", Value: "x"}, "no decided field"},
"wrong type": {seat.ProposalChange{Op: OpDecide, Node: "g14", Field: "hub", Value: "x"}, "does not take"},
"unknown op": {seat.ProposalChange{Op: "move", Node: "g14"}, "no change of placement"},
"pin of nobody": {seat.ProposalChange{Op: OpPin, Node: "g14", Provision: "x"}, "provider"},
"not held": {seat.ProposalChange{Op: OpUnhold, Node: "g14", Module: "app", Claim: "x"}, "holds no"},
"bad port": {seat.ProposalChange{Op: OpPlace, Node: "g14", Module: "x", Ports: map[string]int64{"a": 1}}, "port"},
"seed of another": {seat.ProposalChange{Op: OpSeed, Node: "g14", Whole: &novox}, "seed"},
} {
if _, f := LayChanges(current, []seat.ProposalChange{c.change}); len(f) != 1 || !strings.Contains(f[0], c.want) {
t.Errorf("%s: %v, want a finding naming %q", name, f, c.want)
}
}
})
t.Run("a proposal, its digests and its refusals", func(t *testing.T) {
if got := NewProposalID(time.Date(2026, 10, 28, 18, 30, 0, 0, time.UTC)); !strings.HasPrefix(got, "p-20261028t183000-") || client.CheckProposalID(got) != nil {
t.Errorf("NewProposalID = %q", got)
}
p := seat.Proposal{ID: id, By: "operator@anchor", Why: "move the app", At: time.Unix(1, 0).UTC(),
Changes: []seat.ProposalChange{{Op: OpTakeOff, Node: "g14", Module: "zsh"}}, Base: map[string]int64{"g14": 4},
State: ProposalAnswering}
raw, err := EncodeProposal(p)
if err != nil {
t.Fatal(err)
}
if back, err := DecodeProposal(raw); err != nil || back.ID != id || back.Base["g14"] != 4 || !reflect.DeepEqual(Nodes(back), []string{"g14"}) {
t.Fatalf("round trip %+v, %v", back, err)
}
if _, err := DecodeProposal([]byte(`{"v":9}`)); !errors.Is(err, keys.ErrUnknownVersion) {
t.Errorf("version 9: %v", err)
}
seed := p
seed.Seed, seed.ExportDigest = true, ExportDigest(current)
seed.Changes = []seat.ProposalChange{{Op: OpSeed, Node: "g14", Whole: &g14}}
if _, err := EncodeProposal(seed); err != nil {
t.Errorf("the seed: %v", err)
}
back := p
back.HandBack, back.Changes = true, nil
if _, err := EncodeProposal(back); err != nil {
t.Errorf("a hand-back: %v", err)
}
// The digests are what the earlier holder's code made of the same values (issue 1966: a proposal's ask binds its
// changes' digest, and a seed its export's), whatever order the generated types keep their fields in.
rich := seat.Proposal{ID: id, By: "operator@anchor", Why: "w", At: time.Unix(1, 0).UTC(), State: ProposalAnswering,
Base: map[string]int64{"g14": 4, "lab": 0, "novox": 2},
Changes: []seat.ProposalChange{
{Op: OpTakeOff, Node: "g14", Module: "zsh"},
{Op: OpPlace, Node: "g14", Module: "new", Ports: map[string]int64{"80": 8080}, Bindings: map[string]seat.Provider{"pg": {Node: "novox", Module: "postgres"}}},
{Op: OpPin, Node: "g14", Provision: "shell", Provider: &seat.Provider{Node: "g14", Module: "bash"}},
{Op: OpHold, Node: "g14", Module: "app", Claim: "node-shell", Scope: "node", Kind: "k", Site: "s"},
{Op: OpDecide, Node: "novox", Field: "site", Value: "attic"},
{Op: OpDecide, Node: "novox", Field: "peer-names", Value: map[string]any{"k": "v"}},
{Op: OpConfig, Node: "g14", Module: "app", MeshWide: map[string]any{"a": float64(1)}, Layer: map[string]any{"b": "c"}, MeshWideValues: map[string]any{"d": true}, Values: map[string]any{"e": []any{"f"}}},
{Op: OpAddNode, Node: "lab", Decided: &seat.NodeDecided{Address: "10.0.0.7", Account: "x", AgentAccountHome: "/h"}, Keys: &seat.ProposalChangeKeys{Overlay: "wg", Identity: "id", Sealing: "se"}},
}}
for name, c := range map[string]struct{ got, want string }{
"export": {ExportDigest(current), "sha256:742f03d8790d7efa6abff76636f4a69ba2704d646233960f52ce08604b96e128"},
"rich changes": {ChangesDigest(rich), "sha256:056338a2143d8c1af4e1275f41ffd06f18f3af3396a196d4444df83a0434475c"},
"take-off": {ChangesDigest(p), "sha256:7ce48744dea7934e6f3a6a1eeffb28df4096ee6a6a6b4ae29e15fb019044d8de"},
"seed": {ChangesDigest(seed), "sha256:731fe40d188acdf4a27ea73f3a5f1f9d584c3a8af7eaf15d4fb18e5da4853515"},
"no changes": {ChangesDigest(seat.Proposal{ID: id, Base: map[string]int64{"g14": 4}}), "sha256:3c5f1509c2d1c867954c47303e8b8d84bd4cd9aa3458464c21933c6496999bee"},
} {
if c.got != c.want {
t.Errorf("the digest of %s is %s, and the earlier holder made %s", name, c.got, c.want)
}
}
if ExportDigest(current) != ExportDigest(map[string]seat.Placement{"novox": novox, "g14": g14}) || ExportDigest(current) == ExportDigest(nil) {
t.Error("the export digest depends on order or not on the placements")
}
// 774/S42/AC1 (ADR 0587 §7): an add-node carries its enrolment's warrant naming its node and address, as its one
// change, from the enroller alone; a warrant naming another node or address is refused.
// At the operator's yes it carries the tunnel key alone (the operator, 2026-11-02 01:05).
joining := seat.ProposalChange{Op: OpAddNode, Node: "lab", Decided: &seat.NodeDecided{Address: "10.0.0.7"},
Keys: &seat.ProposalChangeKeys{Overlay: "wg"}}
add := seat.Proposal{ID: id, By: "ace.mesh-enrolment", Why: "lab joins", At: time.Unix(1, 0).UTC(),
Changes: []seat.ProposalChange{joining}, Base: map[string]int64{"lab": 0}, State: ProposalAnswering,
Warrant: &seat.Warrant{Node: "lab", Address: "10.0.0.7", Ask: "a-1", Warrant: "w-1"}}
if raw, err := EncodeProposal(add); err != nil {
t.Errorf("an add-node: %v", err)
} else if back, err := DecodeProposal(raw); err != nil || back.Warrant == nil || back.Changes[0].Keys.Overlay != "wg" {
t.Errorf("an add-node round trip: %+v, %v", back, err)
}
if AddNodeRefusal(add, enroller, enroller) != "" || !strings.Contains(AddNodeRefusal(add, "mesh-delivery-assigner", enroller), "mesh-enrolment's alone") {
t.Errorf("add-node by caller: %q, %q", AddNodeRefusal(add, enroller, enroller), AddNodeRefusal(add, "mesh-delivery-assigner", enroller))
}
if laid, f := LayChanges(current, add.Changes); len(f) != 0 || laid["lab"].Decided.Address != "10.0.0.7" || len(laid["lab"].Modules) != 0 {
t.Errorf("an add-node laid: %+v, %v", laid, f)
}
if _, f := LayChanges(current, []seat.ProposalChange{{Op: OpAddNode, Node: "g14", Decided: &seat.NodeDecided{Address: "10.0.0.9"},
Keys: &seat.ProposalChangeKeys{Overlay: "wg"}}}); len(f) != 1 || !strings.Contains(f[0], "placed already") {
t.Errorf("an add-node of a placed node: %v", f)
}
for name, mut := range map[string]func(*seat.Proposal){
"warrant of another node": func(p *seat.Proposal) {
p.Warrant = &seat.Warrant{Node: "pi", Address: "10.0.0.7", Ask: "a", Warrant: "w"}
},
"warrant of another address": func(p *seat.Proposal) {
p.Warrant = &seat.Warrant{Node: "lab", Address: "10.0.0.8", Ask: "a", Warrant: "w"}
},
"no warrant": func(p *seat.Proposal) { p.Warrant = nil },
"no tunnel key": func(p *seat.Proposal) {
c := joining
c.Keys = &seat.ProposalChangeKeys{Identity: "id", Sealing: "seal"}
p.Changes = []seat.ProposalChange{c}
},
"half its identity keys": func(p *seat.Proposal) {
c := joining
c.Keys = &seat.ProposalChangeKeys{Overlay: "wg", Identity: "id"}
p.Changes = []seat.ProposalChange{c}
},
"beside another change": func(p *seat.Proposal) {
p.Changes = append(p.Changes, seat.ProposalChange{Op: OpPlace, Node: "lab", Module: "zsh"})
},
"the hub": func(p *seat.Proposal) {
c := joining
c.Decided = &seat.NodeDecided{Address: "10.0.0.7", Hub: true}
p.Changes = []seat.ProposalChange{c}
},
"a warrant on a move": func(p *seat.Proposal) {
p.Changes, p.Base = []seat.ProposalChange{{Op: OpTakeOff, Node: "g14", Module: "zsh"}}, map[string]int64{"g14": 4}
},
} {
q := add
q.Changes = append([]seat.ProposalChange(nil), add.Changes...)
mut(&q)
if _, err := EncodeProposal(q); err == nil {
t.Errorf("an add-node with %s: taken", name)
}
}
// With its identity keys whole, as bound at redeem, it is taken too.
whole := add
whole.Changes = []seat.ProposalChange{joining}
whole.Changes[0].Keys = &seat.ProposalChangeKeys{Overlay: "wg", Identity: "id", Sealing: "seal"}
if _, err := EncodeProposal(whole); err != nil {
t.Errorf("an add-node with its identity keys: %v", err)
}
// An enrolment's end takes its never-confirmed node off again: the enroller's alone, of a node running nothing.
remove := add
remove.Changes = []seat.ProposalChange{{Op: OpRemoveNode, Node: "lab"}}
if _, err := EncodeProposal(remove); err != nil || AddNodeRefusal(remove, "mesh-delivery-assigner", enroller) == "" ||
AddNodeRefusal(remove, enroller, enroller) != "" || !slices.Equal(RemovedNodes(remove), []string{"lab"}) {
t.Errorf("a remove-node: %v, %q", err, AddNodeRefusal(remove, "mesh-delivery-assigner", enroller))
}
joined := map[string]seat.Placement{"lab": {V: keys.Version, Node: "lab", Decided: seat.NodeDecided{Address: "10.0.0.7"}, Modules: []seat.PlacedModule{}}}
if laid, f := LayChanges(joined, remove.Changes); len(f) != 0 || len(laid) != 0 {
t.Errorf("a joining node removed: %v, %v", laid, f)
}
if _, f := LayChanges(current, []seat.ProposalChange{{Op: OpRemoveNode, Node: "g14"}}); len(f) != 1 ||
!strings.Contains(f[0], "runs modules") {
t.Errorf("a running node removed: %v", f)
}
remove.Warrant = &seat.Warrant{Node: "pi", Address: "10.0.0.7", Ask: "a", Warrant: "w"}
if _, err := EncodeProposal(remove); err == nil {
t.Error("a remove-node on another node's warrant: taken")
}
for name, mut := range map[string]func(*seat.Proposal){
"no id": func(p *seat.Proposal) { p.ID = "" },
"no proposer": func(p *seat.Proposal) { p.By = "" },
"no why": func(p *seat.Proposal) { p.Why = " " },
"no change": func(p *seat.Proposal) { p.Changes = nil },
"no base": func(p *seat.Proposal) { p.Base = map[string]int64{"novox": 1} },
"no state": func(p *seat.Proposal) { p.State = "" },
"asked, no ask": func(p *seat.Proposal) { p.State = ProposalAsked },
"seed mixed": func(p *seat.Proposal) { p.Seed, p.ExportDigest = true, "d" },
"bad change": func(p *seat.Proposal) { p.Changes[0].Op = "swap" },
"seed, no digest": func(p *seat.Proposal) { *p = seed; p.ExportDigest = "" },
"hand-back with changes": func(p *seat.Proposal) { p.HandBack = true },
} {
q := p
q.Changes = append([]seat.ProposalChange(nil), p.Changes...)
mut(&q)
if _, err := EncodeProposal(q); err == nil {
t.Errorf("%s: taken", name)
}
}
})
// ADR 0571 clause 4: who holds which seat, from placement; a node seat by its node.
t.Run("who holds which seat", func(t *testing.T) {
held := SeatHoldersOf(map[string]seat.Placement{
"ace": {Node: "ace", Holds: []seat.Holding{{Claim: "mesh-dns-resolver", Scope: "mesh", Module: "dnsmasq"}, {Claim: "node-files", Scope: "node", Module: "mesh-files"}}},
"novox": {Node: "novox", Holds: []seat.Holding{{Claim: "mesh-dns-resolver", Scope: "mesh", Module: "dnsmasq"}, {Claim: "mesh-broker", Scope: "mesh", Module: "nats"},
{Claim: "photo-gallery", Scope: "mesh", Module: "photos"}}},
})
if o := held["photo-gallery"]; !o.Outside || len(o.Holders) != 1 || held["mesh-broker"].Outside {
t.Errorf("a claim outside the set is %+v", o)
}
keyed := map[string][]byte{}
for k, v := range held {
keyed[k], _ = json.Marshal(v)
}
if h, err := client.HoldersOf(keyed, "mesh-dns-resolver", ""); err != nil || len(h) != 2 || h[0].Node != "ace" {
t.Errorf("a replicated seat's holders are %v, %v", h, err)
}
if h, _ := client.HoldersOf(keyed, "node-files", "ace"); len(h) != 1 || h[0].Module != "mesh-files" || len(held) != 4 {
t.Errorf("a node seat's holder is %v, keys %v", h, held)
}
if h, _ := client.HoldersOf(keyed, "node-files", "novox"); h != nil {
t.Errorf("a node seat nobody holds there is held by %v", h)
}
})
}
@@ -0,0 +1,435 @@
package placement
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
client "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient"
seat "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// A placement changes only by a proposal (novox/hq ADR 0567 §2): `propose` takes why and a list of changes; the holder
// lays them over the current keys, names the revision of each key it laid them over, and either refuses at once with every
// finding or writes the proposal under its id in its state `proposals` and a candidate for each node it must hear from.
// The operations a proposal's change names (ADR 0567 §2).
const (
OpPlace = "place" // place a module on a node, with its host ports and bindings
OpTakeOff = "take-off" // take a module off a node
OpPin = "pin" // pin the provider of a provision on a node
OpUnpin = "unpin" // remove a pin
OpHold = "hold" // a module placed on the node holds a seat
OpUnhold = "unhold" // it holds it no more
OpDecide = "decide" // set a decided field of the node (NodeDecided by its JSON name), its address among them
OpConfig = "config" // set a module's config placement or values on the node
OpSeed = "seed" // the node's whole placement: the take-over (adopt, §7), never given by a caller
// OpAddNode adds a joining node with its decided fields and the public halves of its keys, on the warrant of the
// enrolment that issued its token (novox/hq ADR 0587 §7): the enroller's alone, the proposal's one change.
OpAddNode = "add-node"
// OpRemoveNode takes a joining node off placement again when its enrolment ends unconfirmed, revoked or expired
// (the operator's decision of 2026-11-02 01:05): the enroller's alone, on the same warrant, the proposal's one
// change, of a node with no module and no holding. The holder deletes its key (RemovedNodes).
OpRemoveNode = "remove-node"
)
// The states of an open proposal.
const (
// ProposalAnswering: its candidates are written and the nodes it must hear from have not all answered.
ProposalAnswering = "answering"
// ProposalFailed: its verdict failed; it stays open, for its proposer to read and withdraw, or for a node's later
// answer to judge it again.
ProposalFailed = "failed"
// ProposalAsked: its verdict passed and the operator is asked to approve it.
ProposalAsked = "asked"
)
// VerdictWaits is a proposal's verdict while a node it must hear from has not answered within the bound; it passes or
// fails as a check's verdict does (ADR 0567 §4).
const VerdictWaits = "waits"
// VerdictPass and VerdictFail are the other two states of a proposal's verdict.
const (
VerdictPass = "pass"
VerdictFail = "fail"
)
// Nodes is every node whose key the proposal changes, sorted.
func Nodes(p seat.Proposal) []string { return sortedKeys(p.Base) }
// RemovedNodes are the nodes whose key a proposal deletes (its remove-node), which LayChanges leaves out of what it lays.
func RemovedNodes(p seat.Proposal) []string {
var out []string
for _, c := range p.Changes {
if c.Op == OpRemoveNode {
out = append(out, c.Node)
}
}
return out
}
// CheckChange refuses a change that names what its operation needs not, or an operation that is none.
func CheckChange(c seat.ProposalChange) error {
if err := keys.CheckName("node", c.Node); err != nil {
return err
}
needs := func(what string, ok bool) error {
if !ok {
return fmt.Errorf("placement: a %s change of %s names no %s", c.Op, c.Node, what)
}
return nil
}
switch c.Op {
case OpPlace, OpTakeOff, OpConfig:
return needs("module", keys.CheckName("module", c.Module) == nil)
case OpPin:
return needs("provision or provider", c.Provision != "" && c.Provider != nil && c.Provider.Node != "" && c.Provider.Module != "")
case OpUnpin:
return needs("provision", c.Provision != "")
case OpHold, OpUnhold:
return needs("module or claim", keys.CheckName("module", c.Module) == nil && c.Claim != "")
case OpDecide:
return needs("field or value", c.Field != "" && c.Value != nil)
case OpSeed:
if c.Whole == nil || c.Whole.Node != c.Node {
return fmt.Errorf("placement: the seed of %s names no placement of that node", c.Node)
}
return client.CheckPlacement(*c.Whole)
case OpAddNode:
if c.Decided == nil || c.Decided.Address == "" || c.Keys == nil || c.Keys.Overlay == "" {
return fmt.Errorf("placement: the add-node of %s names no address or no tunnel key", c.Node)
}
// The identity keys come at redeem, whole or not at all: an identity with no sealing key, or the reverse.
if (c.Keys.Identity == "") != (c.Keys.Sealing == "") || (c.Keys.Identity == "" && c.Keys.Serving != "") {
return fmt.Errorf("placement: the add-node of %s names part of its identity keys; they come whole at redeem", c.Node)
}
if c.Decided.Hub {
return fmt.Errorf("placement: the add-node of %s makes it the hub, which a joining node never is", c.Node)
}
if c.Module != "" || len(c.Ports)+len(c.Bindings) > 0 || c.Whole != nil {
return fmt.Errorf("placement: the add-node of %s places a module; a module is placed by a proposal of its own", c.Node)
}
return nil
case OpRemoveNode:
if c.Module != "" || c.Decided != nil || c.Keys != nil || c.Whole != nil {
return fmt.Errorf("placement: the remove-node of %s names more than its node", c.Node)
}
return nil
}
return fmt.Errorf("placement: %q is no change of placement: place, take-off, pin, unpin, hold, unhold, decide, config, add-node or remove-node", c.Op)
}
// LayChanges is the placements of the nodes the changes name with the changes laid over current, in the order given;
// current is every node's placement now (absent: the node has no key). It returns, by node, the laid placement, and
// every finding: a change that does not fit its node (a module taken off that is not placed, configured or holding
// there; a field NodeDecided does not have) or a placement the client refuses. What CheckPlacements refuses across nodes
// is the caller's to ask of the result laid over current.
func LayChanges(current map[string]seat.Placement, changes []seat.ProposalChange) (map[string]seat.Placement, []string) {
laid := map[string]seat.Placement{}
var findings []string
of := func(node string) seat.Placement {
if p, ok := laid[node]; ok {
return p
}
p, ok := current[node]
if !ok {
return seat.Placement{V: keys.Version, Node: node, Modules: []seat.PlacedModule{}}
}
return clonePlacement(p)
}
for i, c := range changes {
if err := CheckChange(c); err != nil {
findings = append(findings, fmt.Sprintf("change %d: %v", i+1, err))
continue
}
p := of(c.Node)
at := -1
for j, m := range p.Modules {
if m.Module == c.Module {
at = j
}
}
notPlaced := func() {
findings = append(findings, fmt.Sprintf("change %d: %s is not placed on %s", i+1, c.Module, c.Node))
}
switch c.Op {
case OpSeed:
p = clonePlacement(*c.Whole)
case OpAddNode:
// A joining node has no key yet (ADR 0587 §7): one that has is no joining node.
if _, has := current[c.Node]; has {
findings = append(findings, fmt.Sprintf("change %d: %s is placed already, so it does not join", i+1, c.Node))
continue
}
p = seat.Placement{V: keys.Version, Node: c.Node, Decided: *c.Decided, Modules: []seat.PlacedModule{}}
case OpRemoveNode:
// Only a joining node never confirmed goes this way: one with no module and no holding (a node that runs
// anything is taken off module by module, by a proposal the operator approves).
cur, has := current[c.Node]
if !has {
findings = append(findings, fmt.Sprintf("change %d: %s is not placed, so there is nothing to remove", i+1, c.Node))
} else if len(cur.Modules)+len(cur.Holds) > 0 {
findings = append(findings, fmt.Sprintf("change %d: %s runs modules or holds seats, so it is no joining node to remove", i+1, c.Node))
}
continue
case OpPlace:
m := seat.PlacedModule{Module: c.Module, Ports: c.Ports, Bindings: c.Bindings}
if at >= 0 {
o := p.Modules[at]
m.MeshWide, m.HasMeshWide, m.Layer, m.HasLayer = o.MeshWide, o.HasMeshWide, o.Layer, o.HasLayer
m.MeshWideValues, m.Values = o.MeshWideValues, o.Values
p.Modules[at] = m
} else {
p.Modules = append(p.Modules, m)
}
case OpTakeOff:
if at < 0 {
notPlaced()
continue
}
p.Modules = append(p.Modules[:at:at], p.Modules[at+1:]...)
var holds []seat.Holding
for _, h := range p.Holds {
if h.Module != c.Module {
holds = append(holds, h)
}
}
p.Holds = holds
case OpConfig:
if at < 0 {
notPlaced()
continue
}
m := &p.Modules[at]
if c.MeshWide != nil {
m.MeshWide, m.HasMeshWide = c.MeshWide, true
}
if c.Layer != nil {
m.Layer, m.HasLayer = c.Layer, true
}
if c.MeshWideValues != nil {
m.MeshWideValues = c.MeshWideValues
}
if c.Values != nil {
m.Values = c.Values
}
case OpPin:
if p.Pins == nil {
p.Pins = map[string]seat.Provider{}
}
p.Pins[c.Provision] = *c.Provider
case OpUnpin:
delete(p.Pins, c.Provision)
if len(p.Pins) == 0 {
p.Pins = nil
}
case OpHold:
if at < 0 {
notPlaced()
continue
}
p.Holds = append(p.Holds, seat.Holding{Claim: c.Claim, Scope: c.Scope, Module: c.Module, Site: c.Site, Kind: c.Kind})
case OpUnhold:
kept := p.Holds[:0:0]
for _, h := range p.Holds {
if h.Module != c.Module || h.Claim != c.Claim {
kept = append(kept, h)
}
}
if len(kept) == len(p.Holds) {
findings = append(findings, fmt.Sprintf("change %d: %s holds no %s on %s", i+1, c.Module, c.Claim, c.Node))
continue
}
p.Holds = kept
case OpDecide:
d, err := decide(p.Decided, c.Field, c.Value)
if err != nil {
findings = append(findings, fmt.Sprintf("change %d: %v", i+1, err))
continue
}
p.Decided = d
}
laid[c.Node] = p
}
for _, node := range sortedKeys(laid) {
if err := client.CheckPlacement(laid[node]); err != nil {
findings = append(findings, err.Error())
}
}
return laid, findings
}
// decide is d with the field named by its JSON name set to value, refused when NodeDecided has no such field or the
// value does not fit it.
func decide(d seat.NodeDecided, field string, value any) (seat.NodeDecided, error) {
raw, _ := json.Marshal(d)
var fields map[string]json.RawMessage
_ = json.Unmarshal(raw, &fields)
if fields == nil {
fields = map[string]json.RawMessage{}
}
if !decidedFields[field] {
return d, fmt.Errorf("a node has no decided field %q", field)
}
set, err := json.Marshal(value)
if err != nil {
return d, fmt.Errorf("the decided field %q does not take %v: %v", field, value, err)
}
fields[field] = set
raw, _ = json.Marshal(fields)
var out seat.NodeDecided
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
if err := dec.Decode(&out); err != nil {
return d, fmt.Errorf("the decided field %q does not take %s: %v", field, set, err)
}
return out, nil
}
// decidedFields are NodeDecided's fields by their JSON names.
var decidedFields = func() map[string]bool {
raw, _ := json.Marshal(seat.NodeDecided{Endpoint: "x", Site: "x", Hub: true, Address: "x", PublicDomain: "x", Account: "x",
AccountHome: "x", AgentAccount: "x", AgentAccountHome: "x", Adopted: true, Taken: []string{"x"}, Tunnel: &seat.FoundTunnel{Interface: "x"},
PeerNames: map[string]string{"x": "x"}})
var m map[string]any
_ = json.Unmarshal(raw, &m)
out := map[string]bool{}
for k := range m {
out[k] = true
}
return out
}()
func clonePlacement(p seat.Placement) seat.Placement {
raw, _ := json.Marshal(p)
var out seat.Placement
_ = json.Unmarshal(raw, &out)
if out.Modules == nil {
out.Modules = []seat.PlacedModule{}
}
return out
}
// NewProposalID is a fresh proposal id: its time in UTC and six random hex digits, `p-20261028t183000-1a2b3c`. The
// client's CheckProposalID says what an id is.
func NewProposalID(at time.Time) string {
var b [3]byte
_, _ = rand.Read(b[:])
return "p-" + strings.ToLower(at.UTC().Format("20060102T150405")) + "-" + hex.EncodeToString(b[:])
}
// CheckProposal refuses a proposal that is none: an id that is none, no proposer or why, no change (but a hand-back), a
// change it does not take or of a node with no base revision, the seed mixed with other changes or without its export
// digest, a state it does not know, and an add-node or remove-node that is not as ADR 0587 §7 says.
func CheckProposal(p seat.Proposal) error {
if err := client.CheckProposalID(p.ID); err != nil {
return err
}
if strings.TrimSpace(p.By) == "" || strings.TrimSpace(p.Why) == "" {
return fmt.Errorf("placement: the proposal %s names no proposer or no why", p.ID)
}
if len(p.Base) == 0 || (len(p.Changes) == 0) != p.HandBack || (p.HandBack && p.Seed) {
return fmt.Errorf("placement: the proposal %s changes nothing, or is a hand-back with changes", p.ID)
}
for i, c := range p.Changes {
if err := CheckChange(c); err != nil {
return fmt.Errorf("placement: the proposal %s, change %d: %w", p.ID, i+1, err)
}
if (c.Op == OpSeed) != p.Seed {
return fmt.Errorf("placement: the proposal %s mixes the seed with other changes", p.ID)
}
if _, ok := p.Base[c.Node]; !ok {
return fmt.Errorf("placement: the proposal %s changes %s and names no base revision of its key", p.ID, c.Node)
}
}
switch p.State {
case ProposalAnswering, ProposalFailed:
case ProposalAsked:
if p.Ask == "" || p.Verdict == nil || p.Verdict.State != VerdictPass {
return fmt.Errorf("placement: the proposal %s is asked with no ask or no passing verdict", p.ID)
}
default:
return fmt.Errorf("placement: the proposal %s is in no state %q", p.ID, p.State)
}
if p.Seed && p.ExportDigest == "" {
return fmt.Errorf("placement: the seed %s names no export digest", p.ID)
}
return checkAddNode(p)
}
// checkAddNode refuses an add-node or a remove-node that is not the proposal's one change, carries no warrant, or whose
// warrant names another node (or, for an add-node, another address) than the change; and a warrant on any other
// proposal (ADR 0587 §7).
func checkAddNode(p seat.Proposal) error {
adds := 0
for _, c := range p.Changes {
if c.Op == OpAddNode || c.Op == OpRemoveNode {
adds++
}
}
if adds == 0 {
if p.Warrant != nil {
return fmt.Errorf("placement: the proposal %s carries an enrolment's warrant and adds no node", p.ID)
}
return nil
}
if adds != 1 || len(p.Changes) != 1 || p.HandBack || p.Seed {
return fmt.Errorf("placement: the proposal %s adds a node beside other changes; an add-node is a proposal's one change", p.ID)
}
c, w := p.Changes[0], p.Warrant
if w == nil || w.Ask == "" || w.Warrant == "" {
return fmt.Errorf("placement: the add-node of %s carries no warrant of its enrolment", c.Node)
}
if c.Op == OpRemoveNode {
if w.Node != c.Node {
return fmt.Errorf("placement: the remove-node of %s carries the warrant of %s", c.Node, w.Node)
}
return nil
}
if w.Node != c.Node || w.Address != c.Decided.Address {
return fmt.Errorf("placement: the add-node of %s at %s carries the warrant of %s at %s", c.Node, c.Decided.Address, w.Node, w.Address)
}
return nil
}
// AddNodeRefusal is why the holder of the seat refuses an add-node or a remove-node from caller, the module the runtime
// proved called (ADR 0587 §7), or "": only the enroller (the module holding the seat mesh-node-enroller, which the caller
// of this names) adds a node; what the proposal itself must hold, CheckProposal refuses.
func AddNodeRefusal(p seat.Proposal, caller, enroller string) string {
if err := checkAddNode(p); err != nil {
return err.Error()
}
if p.Warrant != nil && caller != enroller {
return fmt.Sprintf("an add-node or a remove-node is %s's alone, and %s proposed it", enroller, caller)
}
return ""
}
// EncodeProposal is p as JSON at the keys' Version; it refuses what DecodeProposal refuses.
func EncodeProposal(p seat.Proposal) ([]byte, error) {
if err := CheckProposal(p); err != nil {
return nil, err
}
p.V = keys.Version
return json.Marshal(p)
}
// DecodeProposal reads a proposal; it refuses an unknown version, a field the contract does not have, and what
// CheckProposal refuses.
func DecodeProposal(data []byte) (seat.Proposal, error) {
if err := keys.CheckVersion(data); err != nil {
return seat.Proposal{}, err
}
p, err := seat.DecodeProposalsByProposalValue(data)
if err != nil {
return seat.Proposal{}, fmt.Errorf("placement: a proposal does not fit its shape: %w", err)
}
if err := CheckProposal(p); err != nil {
return seat.Proposal{}, err
}
return p, nil
}
@@ -0,0 +1,25 @@
package placement
import seat "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
// Module is the module that holds the seat mesh-module-assigner: the states of the seat are named after it, as every
// module state is (a bucket is `<module>_<state>`), and its manifest declares them.
const Module = "mesh-delivery-assigner"
// The buckets of the seat's states, which only the holder writes (ADR 0567 §1).
const (
PlacementBucket = Module + "_" + seat.StatePlacement
ProposalsBucket = Module + "_" + seat.StateProposals
CandidatesBucket = Module + "_" + seat.StateCandidates
PulsesBucket = Module + "_" + seat.StatePulses
SeatHoldersBucket = Module + "_" + seat.StateSeatHolders
)
const (
// PlacementHistory is the revisions the placement bucket keeps per key: the bus's most (§6).
PlacementHistory = 64
// Stream is the holder's stream of the seat's events, kept StreamDays (§6), and declared as the holder's data so the
// mesh's backups keep it.
Stream = "placement"
StreamDays = 30
)
@@ -9,9 +9,10 @@ import (
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/views"
plc "mesh-delivery-assigner/internal/placement"
)
func load(t *testing.T) catalogue.Manifest {
@@ -102,9 +103,9 @@ func TestManifest(t *testing.T) {
if len(m.Streams) != 1 {
t.Errorf("streams = %+v, want placement alone", m.Streams)
}
if m.Streams[0].Name != delivery.PlacementStream || m.Streams[0].MaxAgeDays != delivery.PlacementStreamDays ||
!slices.Equal(slices.Sorted(slices.Values(m.Streams[0].Events)), slices.Sorted(slices.Values(delivery.PlacementEvents))) {
t.Errorf("streams = %+v, want placement carrying the seat's four events for %d days", m.Streams, delivery.PlacementStreamDays)
if m.Streams[0].Name != plc.Stream || m.Streams[0].MaxAgeDays != plc.StreamDays ||
!slices.Equal(slices.Sorted(slices.Values(m.Streams[0].Events)), slices.Sorted(slices.Values(meshmoduleassigner.Events()))) {
t.Errorf("streams = %+v, want placement carrying the seat's four events for %d days", m.Streams, plc.StreamDays)
}
// ADR 0618 §4: and it hears each node's engine say its reads changed.
if !slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.graph", "mesh-delivery-indexer.views.manifests", "mesh-delivery-indexer.views.modules", "node-engine.manifest-reads-changed"}) {