diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/enrolment_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/enrolment_test.go index bb157f87..1c6513ea 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/enrolment_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/enrolment_test.go @@ -20,6 +20,7 @@ func TestIssueTokensAndTheManifest(t *testing.T) { {"S54/AC1 tokens answers each stage and relays the sealed token of one node", tokensAnswersEachStageAndRelaysTheSealedTokenOfOneNode}, {"S54/AC2 the manifest says what the code does", theManifestSaysWhatTheCodeDoes}, {"S56 the bus node's loaded list is read from the grants part's pass", theLoadedListIsReadFromTheGrantsPass}, + {"DLV-M6 the placement is read from its first view, never from the assigner's state", thePlacementIsReadFromItsFirstView}, } { t.Run(c.name, c.run) } @@ -59,3 +60,30 @@ func theLoadedListIsReadFromTheGrantsPass(t *testing.T) { t.Error("no pass composed read as an answer") } } + +// thePlacementIsReadFromItsFirstView: the holder's placement comes from the picture of the assigner's placement (its first +// view and views family, ADR 0615 §1), every other state through the runtime; the runtime is never asked for placement. +func thePlacementIsReadFromItsFirstView(t *testing.T) { + pics := memPictures{PlacementRead: {"joiner.node": `{"v":1,"node":"joiner","decided":{"address":"10.9.0.11"},"modules":[]}`}} + rest := memRead{PlacementRead: {"stale.node": `{"v":1,"node":"stale","modules":[]}`}, "node-engine.have": {}} + h := &Holder{Read: followedReader{pics: pics, rest: rest}} + p, err := h.placements() + if err != nil { + t.Fatal(err) + } + if _, ok := p["joiner"]; !ok || len(p) != 1 { + t.Errorf("placements = %v, want joiner's alone, from the first view", p) + } + if keys, err := h.Read.Keys("node-engine.have"); err != nil || len(keys) != 0 { + t.Errorf("node-engine.have read as %v, %v: want it through the runtime", keys, err) + } +} + +// memPictures is the followed states (views.Reader), `.` then key. +type memPictures map[string]map[string]string + +func (p memPictures) Follows(state string) bool { _, ok := p[state]; return ok } +func (p memPictures) Keys(state string) ([]string, error) { + return memRead(p).Keys(state) +} +func (p memPictures) Get(state, key string) ([]byte, bool, error) { return memRead(p).Get(state, key) } diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/holder.go b/modules/mesh-enrolment/cmd/mesh-enrolment/holder.go index 35c52701..3251ef43 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/holder.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/holder.go @@ -189,13 +189,13 @@ func (h *Holder) openEnrolments() (map[string]Row, map[string]Enrolment, error) // placements reads every node's placement, assembled from its keys. func (h *Holder) placements() (map[string]delivery.Placement, error) { - keys, err := h.Read.Keys("mesh-delivery-assigner.placement") + keys, err := h.Read.Keys(PlacementRead) if err != nil { return nil, fmt.Errorf("placement cannot be listed: %w", err) } held := map[string][]byte{} for _, k := range keys { - if raw, found, err := h.Read.Get("mesh-delivery-assigner.placement", k); err != nil { + if raw, found, err := h.Read.Get(PlacementRead, k); err != nil { return nil, fmt.Errorf("placement key %s cannot be read: %w", k, err) } else if found { held[k] = raw diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/main.go b/modules/mesh-enrolment/cmd/mesh-enrolment/main.go index 50169542..778c48d3 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/main.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/main.go @@ -5,6 +5,7 @@ package main import ( + "context" "encoding/json" "errors" "fmt" @@ -13,7 +14,9 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/asker" + "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/handacts" + "git.novox.be/novox/mesh-sdk/go/views" ) func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) } @@ -84,14 +87,58 @@ func (runtimeReader) Get(state, key string) ([]byte, bool, error) { return e.Value, true, nil } +// pictures are the states the enrolment follows rather than reads (views.Reader): the assigner's placement (ADR 0615 §1). +type pictures interface { + Follows(state string) bool + Keys(state string) ([]string, error) + Get(state, key string) ([]byte, bool, error) +} + +// followedReader reads a followed state from its picture and every other state through rest. +type followedReader struct { + pics pictures + rest Reader +} + +func (r followedReader) Keys(state string) ([]string, error) { + if r.pics != nil && r.pics.Follows(state) { + return r.pics.Keys(state) + } + return r.rest.Keys(state) +} + +func (r followedReader) Get(state, key string) ([]byte, bool, error) { + if r.pics != nil && r.pics.Follows(state) { + return r.pics.Get(state, key) + } + return r.rest.Get(state, key) +} + +// PlacementRead is the assigner's placement as the enrolment reads it, and PlacementTool the assigner's read tool the +// manifest invokes for its first view (ADR 0615, DLV-M6). +const ( + PlacementRead = delivery.AssignerModule + "." + delivery.PlacementState + PlacementTool = delivery.AssignerModule + ".assigner_read_placement" +) + +// followed is the enrolment's pictures of the assigner's placement, started at once: its first view asked through +// PlacementTool, then the assigner's views family; past its bound the finding in the enrolment's own findings. +func followed() *views.Reader { + v := &views.Reader{Module: Module, Logf: func(f string, a ...any) { logf("["+Module+"] "+f, a...) }, Findings: views.OwnFindings{}, + Acts: handacts.Recorder{Ask: stdio.Ask}} + v.Follow(delivery.AssignerModule, delivery.PlacementState, PlacementTool) + v.Start(context.Background()) + return v +} + // SeatEventSubject is where the seat says an event: mesh.seat..event.. func SeatEventSubject(event string) string { return "mesh.seat." + Seat + ".event." + event } // newHolder is the holder over the runtime. -func newHolder() *Holder { +func newHolder(v *views.Reader) *Holder { call := Call(stdio.Ask) rec := handacts.Recorder{Ask: stdio.Ask} - rd := runtimeReader{} + rd := followedReader{pics: v, rest: runtimeReader{}} client := asker.New("asked") return &Holder{ Store: runtimeStore{}, Read: rd, Vault: vaultOver{call: call, read: rd}, Asker: client, @@ -169,10 +216,11 @@ func loadedVia(rd Reader) func(string) (bool, error) { } func main() { - h := newHolder() + v := followed() + h := newHolder(v) client := h.Asker.(*asker.Client) h.Run(func() error { return client.Listen(h.Warranted, h.Said) }) - if err := stdio.Serve("", Tools(h)); err != nil { + if err := stdio.Serve("", append(Tools(h), v.Tools("enrolment")...)); err != nil { logf("%v", err) os.Exit(1) } diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go index 668f1931..5141f2af 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go @@ -82,9 +82,11 @@ func theManifestSaysWhatTheCodeDoes(t *testing.T) { Scope string `json:"scope"` Serves []string `json:"serves"` } `json:"claims"` - Emits []string `json:"emits"` - Reads []string `json:"reads"` - State []json.RawMessage `json:"state"` + Emits []string `json:"emits"` + Reads []string `json:"reads"` + Invokes []string `json:"invokes"` + Tools []string `json:"tools"` + State []json.RawMessage `json:"state"` } if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) @@ -96,10 +98,20 @@ func theManifestSaysWhatTheCodeDoes(t *testing.T) { if slices.Contains(m.Reads, "mesh-controller.lease") { t.Error("the manifest reads the controller's lease, which the code no longer reads (issue 1126)") } + // The assigner's placement is followed, never read (ADR 0615 §1, DLV-M6): its read tool invoked, its views family consumed. + for _, r := range m.Reads { + if strings.HasPrefix(r, "mesh-delivery-indexer.") || strings.HasPrefix(r, "mesh-delivery-assigner.") { + t.Errorf("reads %s: the indexer's and the assigner's state is read by their tools and views families alone", r) + } + } + if !slices.Contains(m.Invokes, PlacementTool) || !slices.Equal(m.Tools, []string{"enrolment_first_views", "enrolment_first_view_again"}) { + t.Errorf("invokes %v, tools %v: want %s and the first-view tools", m.Invokes, m.Tools, PlacementTool) + } var heard []string for _, e := range EndEvents { heard = append(heard, Seat+"."+e) } + heard = append(heard, "mesh-delivery-assigner.views.placement") if !slices.Equal(m.Consumes, heard) { t.Errorf("consumes %v, the end events are %v", m.Consumes, heard) } @@ -112,7 +124,7 @@ func theManifestSaysWhatTheCodeDoes(t *testing.T) { t.Errorf("the verb %s has no tool", v) } } - if len(m.State) != 2 || string(m.State[0]) == "" || !slices.ContainsFunc(m.State, func(s json.RawMessage) bool { + if len(m.State) != 3 || string(m.State[0]) == "" || !slices.ContainsFunc(m.State, func(s json.RawMessage) bool { var d struct { Name string `json:"name"` TTL bool `json:"key-ttl"` diff --git a/modules/mesh-enrolment/module.json b/modules/mesh-enrolment/module.json index 4aed2463..2dfb4404 100644 --- a/modules/mesh-enrolment/module.json +++ b/modules/mesh-enrolment/module.json @@ -20,7 +20,8 @@ "mesh-enrolment.node-enrolled", "mesh-enrolment.enrolment-refused", "mesh-enrolment.enrolment-revoked", - "mesh-enrolment.enrolment-expired" + "mesh-enrolment.enrolment-expired", + "mesh-delivery-assigner.views.placement" ], "uses": [ "operator-channel" @@ -31,10 +32,10 @@ "seat:mesh-placement.propose", "seat:mesh-vault.enrolment-token", "seat:mesh-vault.node-account", - "seat:mesh-vault.sign" + "seat:mesh-vault.sign", + "mesh-delivery-assigner.assigner_read_placement" ], "reads": [ - "mesh-delivery-assigner.placement", "mesh-grants.user-list", "mesh-vault.signers", "node-engine.have" @@ -47,7 +48,12 @@ { "name": "asked", "history": 5 - } + }, + "findings" + ], + "tools": [ + "enrolment_first_views", + "enrolment_first_view_again" ], "own-secrets": { "broker": "${dir:state}/broker"