Define how a module announces the states it keeps, so readers are granted from the owner's word (issue 1790, S9, ADR 0495 §2-§3)
announce.States on the module's own event subject mesh.mod.<m>.event.states, kept by the controller in mesh-controller_states-announced by module.
This commit is contained in:
@@ -0,0 +1,126 @@
|
|||||||
|
// Package announce is how a module says on the bus which states it keeps and which of them others may read (novox/hq
|
||||||
|
// ADR 0495 clauses 2 and 3; issue 1790): the subject it says it on, the value it says, and where the controller keeps
|
||||||
|
// the last one said by each module. What a module announced is the truth of what it keeps; a reader is granted a read
|
||||||
|
// from it, never from a list or from the owner's manifest.
|
||||||
|
//
|
||||||
|
// A module announces from its code (process.Conn.Keeps, stdio.Keeps, the TypeScript SDK's keeps), never from its
|
||||||
|
// manifest, which only the core reads (clause 1). A module that keeps no state on the bus announces nothing.
|
||||||
|
package announce
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is the version of the value this package writes.
|
||||||
|
const Version = 1
|
||||||
|
|
||||||
|
// Event is the event type of an announcement, under the module's own namespace.
|
||||||
|
const Event = "states"
|
||||||
|
|
||||||
|
// Subject is where module announces what it keeps: its own event subject, mesh.mod.<module>.event.states, which its
|
||||||
|
// account may already publish to and the events stream keeps.
|
||||||
|
func Subject(module string) string { return "mesh.mod." + module + ".event." + Event }
|
||||||
|
|
||||||
|
// SubjectAll is every module's announcement, what the controller consumes.
|
||||||
|
const SubjectAll = "mesh.mod.*.event." + Event
|
||||||
|
|
||||||
|
// KeptBucket is the controller's bucket of the last announcement of each module, by Key.
|
||||||
|
const KeptBucket = "mesh-controller_states-announced"
|
||||||
|
|
||||||
|
var name = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||||
|
|
||||||
|
// Key is module's key in KeptBucket: the module itself, which must be one plain name, since a module's name is part of
|
||||||
|
// its buckets' names (catalogue.StateProblems).
|
||||||
|
func Key(module string) (string, error) {
|
||||||
|
if !name.MatchString(module) {
|
||||||
|
return "", fmt.Errorf("announce: %q is not a module that keeps state: lower-case letters, digits and hyphens", module)
|
||||||
|
}
|
||||||
|
return module, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// State is one state a module keeps, by its local name, and whether others may read it. Others never write it: a
|
||||||
|
// state another module writes is the owner's own code's business.
|
||||||
|
type State struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Readable bool `json:"readable,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// States is an announcement: the module, the machine whose instance said it, and every state the module keeps, sorted
|
||||||
|
// by name. An empty list says the module keeps nothing.
|
||||||
|
type States struct {
|
||||||
|
V int `json:"v"`
|
||||||
|
Module string `json:"module"`
|
||||||
|
Node string `json:"node,omitempty"`
|
||||||
|
States []State `json:"states"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keeps says whether the module announced keeping the state.
|
||||||
|
func (s States) Keeps(state string) bool {
|
||||||
|
for _, k := range s.States {
|
||||||
|
if k.Name == state {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Readable says whether the module announced the state readable by others.
|
||||||
|
func (s States) Readable(state string) bool {
|
||||||
|
for _, k := range s.States {
|
||||||
|
if k.Name == state {
|
||||||
|
return k.Readable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *States) check() error {
|
||||||
|
if s.Module == "" {
|
||||||
|
return fmt.Errorf("announce: an announcement names no module")
|
||||||
|
}
|
||||||
|
if _, err := Key(s.Module); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, k := range s.States {
|
||||||
|
if !name.MatchString(k.Name) {
|
||||||
|
return fmt.Errorf("announce: %s announces the state %q, and a state is named locally: lower-case letters, digits and hyphens", s.Module, k.Name)
|
||||||
|
}
|
||||||
|
if seen[k.Name] {
|
||||||
|
return fmt.Errorf("announce: %s announces the state %q twice", s.Module, k.Name)
|
||||||
|
}
|
||||||
|
seen[k.Name] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode is s as JSON at Version, its states sorted; it refuses a module or a state no bucket could be named by and a
|
||||||
|
// state announced twice, naming it.
|
||||||
|
func Encode(s States) ([]byte, error) {
|
||||||
|
if err := s.check(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.V = Version
|
||||||
|
s.States = append([]State{}, s.States...)
|
||||||
|
sort.Slice(s.States, func(i, j int) bool { return s.States[i].Name < s.States[j].Name })
|
||||||
|
return json.Marshal(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode reads an announcement; it refuses another version and what Encode refuses.
|
||||||
|
func Decode(data []byte) (States, error) {
|
||||||
|
var s States
|
||||||
|
if err := json.Unmarshal(data, &s); err != nil {
|
||||||
|
return States{}, fmt.Errorf("announce: the announcement is not JSON: %w", err)
|
||||||
|
}
|
||||||
|
if s.V != Version {
|
||||||
|
return States{}, fmt.Errorf("announce: the announcement is version %d, and this reads version %d", s.V, Version)
|
||||||
|
}
|
||||||
|
if err := s.check(); err != nil {
|
||||||
|
return States{}, err
|
||||||
|
}
|
||||||
|
sort.Slice(s.States, func(i, j int) bool { return s.States[i].Name < s.States[j].Name })
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package announce
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A module says on the bus which states it keeps and which of them others may read (novox/hq ADR 0495 clause 2), on its
|
||||||
|
// own event subject, in a versioned value the controller keeps by module.
|
||||||
|
func TestAnAnnouncementRoundTripsSortedAndSaysWhatIsReadable(t *testing.T) {
|
||||||
|
data, err := Encode(States{Module: "mesh-controller", Node: "novox", States: []State{
|
||||||
|
{Name: "plans-building", Readable: true}, {Name: "assignments", Readable: true}, {Name: "own"}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
back, err := Decode(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if back.V != Version || back.Module != "mesh-controller" || back.Node != "novox" {
|
||||||
|
t.Fatalf("decoded %+v", back)
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, s := range back.States {
|
||||||
|
names = append(names, s.Name)
|
||||||
|
}
|
||||||
|
if strings.Join(names, ",") != "assignments,own,plans-building" {
|
||||||
|
t.Fatalf("the states are not sorted: %v", names)
|
||||||
|
}
|
||||||
|
if !back.Readable("plans-building") || back.Readable("own") || back.Readable("absent") {
|
||||||
|
t.Fatal("Readable does not say what was announced readable")
|
||||||
|
}
|
||||||
|
if !back.Keeps("own") || back.Keeps("absent") {
|
||||||
|
t.Fatal("Keeps does not say what was announced")
|
||||||
|
}
|
||||||
|
if Subject("mesh-controller") != "mesh.mod.mesh-controller.event.states" || SubjectAll != "mesh.mod.*.event.states" {
|
||||||
|
t.Fatalf("the subjects are %q and %q", Subject("mesh-controller"), SubjectAll)
|
||||||
|
}
|
||||||
|
if KeptBucket != "mesh-controller_states-announced" {
|
||||||
|
t.Fatalf("the kept bucket is %q", KeptBucket)
|
||||||
|
}
|
||||||
|
if k, err := Key("mesh-delivery-builder"); err != nil || k != "mesh-delivery-builder" {
|
||||||
|
t.Fatalf("Key = %q, %v", k, err)
|
||||||
|
}
|
||||||
|
if nothing, err := Encode(States{Module: "quiet"}); err != nil || !strings.Contains(string(nothing), `"states":[]`) {
|
||||||
|
t.Fatalf("a module keeping nothing: %s, %v", nothing, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAnnouncementRefusesWhatNoBucketCouldBe(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
s States
|
||||||
|
says string
|
||||||
|
}{
|
||||||
|
{States{Module: ""}, "no module"},
|
||||||
|
{States{Module: "a.b"}, `"a.b"`},
|
||||||
|
{States{Module: "a", States: []State{{Name: "x_y"}}}, `"x_y"`},
|
||||||
|
{States{Module: "a", States: []State{{Name: ""}}}, `""`},
|
||||||
|
{States{Module: "a", States: []State{{Name: "x"}, {Name: "x", Readable: true}}}, "twice"},
|
||||||
|
} {
|
||||||
|
if _, err := Encode(c.s); err == nil || !strings.Contains(err.Error(), c.says) {
|
||||||
|
t.Errorf("%+v: %v, wanted a refusal saying %s", c.s, err, c.says)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := Decode([]byte(`{"v":2,"module":"a","states":[]}`)); err == nil {
|
||||||
|
t.Error("an unknown version was read")
|
||||||
|
}
|
||||||
|
if _, err := Key("a.b"); err == nil {
|
||||||
|
t.Error("a module with a dot became a key")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user