Every runtime announces what it serves in the NATS services protocol; the console discovers by asking the bus (hq ADR 0197)
The Go runtime answers $SRV.PING, $SRV.INFO and $SRV.STATS (and per name and id) in the io.nats.micro.v1 format with what it serves at the moment it is asked: one service per runtime process, since the bus admits one reply per request from each responder, and one endpoint per tool per subject, its metadata saying module, seat, scope, machine, description, schema and whether the module is interchangeable. Serving is unchanged. The console gathers one $SRV.INFO request's answers instead of asking the catalogue's roster and each module's tools, and reads the controller's records as JSON for what should have answered: an assignment with tools that did not announce is named, a module without tools never is. The text parsers of node list and module list are gone. Packages share the test bus: go test -p 1.
This commit is contained in:
@@ -19,12 +19,12 @@ package console
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/announce"
|
||||
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
||||
)
|
||||
|
||||
@@ -159,188 +159,181 @@ func controllerOutput(conn *bus.Conn, verb string) (string, error) {
|
||||
// jsonIn is the JSON document a command printed, after any lines it said first: a seat verb runs
|
||||
// the controller's command, and a command may warn before it answers.
|
||||
func jsonIn(output string) string {
|
||||
if strings.HasPrefix(strings.TrimSpace(output), "{") {
|
||||
return strings.TrimSpace(output)
|
||||
t := strings.TrimSpace(output)
|
||||
if strings.HasPrefix(t, "{") || strings.HasPrefix(t, "[") {
|
||||
return t
|
||||
}
|
||||
if i := strings.Index(output, "\n{"); i >= 0 {
|
||||
return strings.TrimSpace(output[i+1:])
|
||||
for _, open := range []string{"\n{", "\n["} {
|
||||
if i := strings.Index(output, open); i >= 0 {
|
||||
return strings.TrimSpace(output[i+1:])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// A machine as `node list` prints it: its name, when it was last heard from, its mode — converged
|
||||
// or adopted, the only two the command prints — and its id. Lines the command says around them
|
||||
// (a warning about the bus's users, "no node records yet") are not machines and are skipped.
|
||||
var nodeLine = regexp.MustCompile(`^([a-z0-9][a-z0-9-]*)\s+.*\s(converged|adopted)\s+\S+\s*$`)
|
||||
|
||||
// machinesIn reads the machines from `node list`'s output.
|
||||
func machinesIn(output string) []string {
|
||||
var out []string
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
if m := nodeLine.FindStringSubmatch(line); m != nil {
|
||||
out = append(out, m[1])
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
// recordedModule is a module as the controller's records hold it (`module list --json`): where the
|
||||
// mesh assigned it, and whether it declares tools — what should announce itself, and where.
|
||||
type recordedModule struct {
|
||||
Module string `json:"module"`
|
||||
On []string `json:"on"`
|
||||
Tools bool `json:"tools"`
|
||||
}
|
||||
|
||||
// A module as `module list` prints it: name, version, how it was built, and where it runs.
|
||||
var moduleLine = regexp.MustCompile(`^([a-z0-9][a-z0-9-]*)\s+\S+\s+.*?\s+on (.+)$`)
|
||||
|
||||
// assignmentsIn reads, from `module list`'s output, the machines each module runs on.
|
||||
func assignmentsIn(output string) map[string][]string {
|
||||
out := map[string][]string{}
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
if line == "" || line[0] == ' ' || line[0] == '\t' {
|
||||
continue
|
||||
}
|
||||
m := moduleLine.FindStringSubmatch(strings.TrimRight(line, " "))
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
on := strings.TrimSpace(m[2])
|
||||
if on == "nothing" {
|
||||
out[m[1]] = []string{}
|
||||
continue
|
||||
}
|
||||
var nodes []string
|
||||
for _, n := range strings.Split(on, ",") {
|
||||
if n = strings.TrimSpace(n); n != "" {
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
}
|
||||
sort.Strings(nodes)
|
||||
out[m[1]] = nodes
|
||||
}
|
||||
return out
|
||||
// recordedMachine is a machine as the controller's records hold it (`node list --json`).
|
||||
type recordedMachine struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// interchangeable is whether the mesh issued the module a plain subject for this tool — one any of
|
||||
// its instances answers (ADR 0160): the module's own subject with no machine after it.
|
||||
func interchangeable(module string, t Tool) bool {
|
||||
plain := "mesh.mod." + module + ".tool." + t.Name
|
||||
for _, s := range t.Subjects {
|
||||
if s == plain {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// indexOn asks the mesh what it holds: the flat listing (catalogue, modules, the seats' tools),
|
||||
// and from the controller the seats' holders, the machines and the assignments — at once.
|
||||
// indexOn asks the mesh what it holds (novox/hq ADR 0197): what answers, from every runtime's own
|
||||
// announcement on the bus — one `$SRV.INFO` request — and what should, from the controller's records
|
||||
// read as JSON. Nothing is inferred from a roster and nothing is parsed from print.
|
||||
func indexOn(conn *bus.Conn) (*index, error) {
|
||||
var wg sync.WaitGroup
|
||||
var seatsOut, nodesOut, modulesOut string
|
||||
wg.Add(3)
|
||||
go func() { defer wg.Done(); seatsOut, _ = controllerOutput(conn, "seats") }()
|
||||
var nodesOut, modulesOut string
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); nodesOut, _ = controllerOutput(conn, "nodes") }()
|
||||
go func() { defer wg.Done(); modulesOut, _ = controllerOutput(conn, "modules") }()
|
||||
l, err := toolsOn(conn)
|
||||
infos, err := announce.Gather(conn)
|
||||
wg.Wait()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
x := &index{Modules: map[string]*moduleInfo{}, NotAnswering: l.NotAnswering, Listing: l}
|
||||
|
||||
// The seats: their verbs from the mesh's records, their holders from the controller.
|
||||
var held struct {
|
||||
Seats []struct {
|
||||
Seat string `json:"seat"`
|
||||
Scope string `json:"scope"`
|
||||
Holders []holder `json:"holders"`
|
||||
} `json:"seats"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(jsonIn(seatsOut)), &held)
|
||||
holders := map[string][]holder{}
|
||||
scopes := map[string]string{}
|
||||
for _, s := range held.Seats {
|
||||
holders[s.Seat] = s.Holders
|
||||
scopes[s.Seat] = s.Scope
|
||||
}
|
||||
bySeat := map[string]*seatInfo{}
|
||||
for _, t := range l.Tools {
|
||||
if !t.Seat {
|
||||
continue
|
||||
}
|
||||
s := bySeat[t.Module]
|
||||
if s == nil {
|
||||
scope := t.Scope
|
||||
if scope == "" {
|
||||
scope = scopes[t.Module]
|
||||
l := &Listing{Tools: []Tool{}, NotAnswering: []string{}}
|
||||
x := &index{Modules: map[string]*moduleInfo{}, Listing: l}
|
||||
announced := map[string]map[string]bool{} // module → node → announced something
|
||||
seats := map[string]*seatInfo{}
|
||||
toolAt := map[string]int{} // <module>.<tool> or <seat>.<verb> → index in l.Tools
|
||||
machines := map[string]bool{}
|
||||
for _, info := range infos {
|
||||
for _, e := range announce.Endpoints(info) {
|
||||
if e.Node != "" {
|
||||
machines[e.Node] = true
|
||||
}
|
||||
if scope != "node" {
|
||||
scope = "mesh"
|
||||
if announced[e.Module] == nil {
|
||||
announced[e.Module] = map[string]bool{}
|
||||
}
|
||||
s = &seatInfo{Seat: t.Module, Scope: scope, Holders: holders[t.Module]}
|
||||
bySeat[t.Module] = s
|
||||
}
|
||||
s.Verbs = append(s.Verbs, t)
|
||||
}
|
||||
for _, s := range bySeat {
|
||||
x.Seats = append(x.Seats, *s)
|
||||
}
|
||||
sort.Slice(x.Seats, func(i, j int) bool { return x.Seats[i].Seat < x.Seats[j].Seat })
|
||||
|
||||
// The machines: what the controller knows, and any that hold a seat.
|
||||
seen := map[string]bool{}
|
||||
for _, n := range machinesIn(nodesOut) {
|
||||
seen[n] = true
|
||||
}
|
||||
for _, s := range x.Seats {
|
||||
for _, h := range s.Holders {
|
||||
if h.Node != "" {
|
||||
seen[h.Node] = true
|
||||
announced[e.Module][e.Node] = true
|
||||
switch e.Kind {
|
||||
case announce.KindSeat:
|
||||
st := seats[e.Seat]
|
||||
if st == nil {
|
||||
st = &seatInfo{Seat: e.Seat, Scope: e.Scope}
|
||||
seats[e.Seat] = st
|
||||
}
|
||||
if st.Scope != "node" && e.Scope == "node" {
|
||||
st.Scope = "node"
|
||||
}
|
||||
h := holder{Module: e.Module, Node: e.Node}
|
||||
if !containsHolder(st.Holders, h) {
|
||||
st.Holders = append(st.Holders, h)
|
||||
}
|
||||
key := e.Seat + "." + e.Tool
|
||||
if _, have := toolAt[key]; !have {
|
||||
toolAt[key] = len(l.Tools)
|
||||
t := Tool{Module: e.Seat, Name: e.Tool, Description: e.Description, Input: e.Schema, Seat: true, Scope: st.Scope}
|
||||
l.Tools = append(l.Tools, t)
|
||||
st.Verbs = append(st.Verbs, t)
|
||||
}
|
||||
case announce.KindTool:
|
||||
m := x.Modules[e.Module]
|
||||
if m == nil {
|
||||
m = &moduleInfo{Module: e.Module}
|
||||
x.Modules[e.Module] = m
|
||||
}
|
||||
if e.Node != "" && !contains(m.On, e.Node) {
|
||||
m.On = append(m.On, e.Node)
|
||||
}
|
||||
m.Interchangeable = m.Interchangeable || e.Interchangeable
|
||||
key := e.Module + "." + e.Tool
|
||||
i, have := toolAt[key]
|
||||
if !have {
|
||||
i = len(l.Tools)
|
||||
toolAt[key] = i
|
||||
l.Tools = append(l.Tools, Tool{Module: e.Module, Name: e.Tool, Description: e.Description, Input: e.Schema})
|
||||
}
|
||||
if !contains(l.Tools[i].Subjects, e.Subject) {
|
||||
l.Tools[i].Subjects = append(l.Tools[i].Subjects, e.Subject)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The modules that answer tools, where they run, and whether any instance will do.
|
||||
on := assignmentsIn(modulesOut)
|
||||
for _, t := range l.Tools {
|
||||
// A tool's subjects as a call looks them up: the plain one any instance answers first, then each
|
||||
// machine's.
|
||||
for i := range l.Tools {
|
||||
t := &l.Tools[i]
|
||||
if t.Seat {
|
||||
continue
|
||||
}
|
||||
m := x.Modules[t.Module]
|
||||
if m == nil {
|
||||
m = &moduleInfo{Module: t.Module, On: on[t.Module]}
|
||||
x.Modules[t.Module] = m
|
||||
}
|
||||
m.Tools = append(m.Tools, t)
|
||||
if interchangeable(t.Module, t) {
|
||||
m.Interchangeable = true
|
||||
plain := "mesh.mod." + t.Module + ".tool." + t.Name
|
||||
sort.SliceStable(t.Subjects, func(a, b int) bool {
|
||||
if (t.Subjects[a] == plain) != (t.Subjects[b] == plain) {
|
||||
return t.Subjects[a] == plain
|
||||
}
|
||||
return t.Subjects[a] < t.Subjects[b]
|
||||
})
|
||||
}
|
||||
for _, t := range l.Tools {
|
||||
if !t.Seat {
|
||||
x.Modules[t.Module].Tools = append(x.Modules[t.Module].Tools, t)
|
||||
}
|
||||
}
|
||||
for _, m := range x.Modules {
|
||||
// A module the controller could not place is placed where its own answer says it runs.
|
||||
if len(m.On) == 0 {
|
||||
nodes := map[string]bool{}
|
||||
for _, t := range m.Tools {
|
||||
for _, s := range t.Subjects {
|
||||
base := "mesh.mod." + m.Module + ".tool." + t.Name + "."
|
||||
if strings.HasPrefix(s, base) {
|
||||
nodes[strings.TrimPrefix(s, base)] = true
|
||||
}
|
||||
sort.Strings(m.On)
|
||||
}
|
||||
for _, st := range seats {
|
||||
sort.Slice(st.Holders, func(a, b int) bool { return st.Holders[a].Node < st.Holders[b].Node })
|
||||
x.Seats = append(x.Seats, *st)
|
||||
}
|
||||
sort.Slice(x.Seats, func(i, j int) bool { return x.Seats[i].Seat < x.Seats[j].Seat })
|
||||
sort.SliceStable(l.Tools, func(i, j int) bool {
|
||||
return l.Tools[i].Module+"."+l.Tools[i].Name < l.Tools[j].Module+"."+l.Tools[j].Name
|
||||
})
|
||||
|
||||
// What should have answered: every assignment of a module that declares tools. Silence is named;
|
||||
// a module with no tools is never a name here.
|
||||
var recorded []recordedModule
|
||||
if json.Unmarshal([]byte(jsonIn(modulesOut)), &recorded) == nil {
|
||||
for _, m := range recorded {
|
||||
if !m.Tools {
|
||||
continue
|
||||
}
|
||||
for _, n := range m.On {
|
||||
if !announced[m.Module][n] {
|
||||
l.NotAnswering = append(l.NotAnswering, m.Module+" on "+n)
|
||||
}
|
||||
}
|
||||
for n := range nodes {
|
||||
m.On = append(m.On, n)
|
||||
}
|
||||
sort.Strings(m.On)
|
||||
}
|
||||
for _, n := range m.On {
|
||||
seen[n] = true
|
||||
} else {
|
||||
l.NotAnswering = append(l.NotAnswering, "mesh-controller (its records of the modules did not answer, so what is missing cannot be said)")
|
||||
}
|
||||
sort.Strings(l.NotAnswering)
|
||||
x.NotAnswering = l.NotAnswering
|
||||
|
||||
var known []recordedMachine
|
||||
if json.Unmarshal([]byte(jsonIn(nodesOut)), &known) == nil {
|
||||
for _, n := range known {
|
||||
if n.Name != "" {
|
||||
machines[n.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for n := range seen {
|
||||
for n := range machines {
|
||||
x.Machines = append(x.Machines, n)
|
||||
}
|
||||
sort.Strings(x.Machines)
|
||||
return x, nil
|
||||
}
|
||||
|
||||
func containsHolder(hs []holder, h holder) bool {
|
||||
for _, x := range hs {
|
||||
if x == h {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Surface) index() (*index, error) {
|
||||
s.mu.Lock()
|
||||
if s.idx != nil && time.Since(s.idxAt) <= IndexKept {
|
||||
@@ -520,7 +513,7 @@ func failure(text string) map[string]any {
|
||||
func (s *Surface) discover(name string, args map[string]any) map[string]any {
|
||||
x, err := s.index()
|
||||
if err != nil {
|
||||
return failure(whyItFailed(catalogueModules, err))
|
||||
return failure("the mesh's discovery failed: " + err.Error())
|
||||
}
|
||||
str := func(k string) string { v, _ := args[k].(string); return strings.TrimSpace(v) }
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/announce"
|
||||
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
|
||||
"github.com/novox/mesh-tools/node-tools/internal/runtime"
|
||||
)
|
||||
@@ -56,14 +58,20 @@ func TestTheMeshsToolsAreFoundByAddress(t *testing.T) {
|
||||
}
|
||||
defer stop()
|
||||
|
||||
catalogue := connect(t, "mesh-catalog", "")
|
||||
stopCat, _ := catalogue.Handle("catalog_modules", func(json.RawMessage) (any, error) {
|
||||
return map[string]any{"modules": []map[string]string{{"module": "alpha"}, {"module": "beta"}, {"module": "gamma"}}}, nil
|
||||
})
|
||||
defer stopCat()
|
||||
// Nothing may be asked for a roster or a module's `tools` any more (ADR 0197): counted.
|
||||
var asked atomic.Int32
|
||||
watcher := connect(t, "watcher", "")
|
||||
for _, subject := range []string{"mesh.mod.*.tool.tools", "mesh.mod.*.tool.tools.*", "mesh.mod.mesh-catalog.>"} {
|
||||
stop, err := watcher.Raw(subject, func(string, []byte) []byte { asked.Add(1); return nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(stop)
|
||||
}
|
||||
|
||||
// The controller, answering as its seat verbs do: the command's printed output.
|
||||
controller := connect(t, "mesh-controller", "")
|
||||
// The controller: its records as JSON, as its seat verbs answer them, and its own seat's verbs
|
||||
// announced on the bus like every runtime's.
|
||||
controller := connect(t, "mesh-controller", "bench")
|
||||
out := func(s string) map[string]any { return map[string]any{"output": s, "ok": true} }
|
||||
serve := func(verb string, answer func() any) {
|
||||
stop, err := controller.HandleSubject("mesh.seat.mesh-controller.tool."+verb, func(json.RawMessage) (any, error) {
|
||||
@@ -74,34 +82,28 @@ func TestTheMeshsToolsAreFoundByAddress(t *testing.T) {
|
||||
}
|
||||
t.Cleanup(stop)
|
||||
}
|
||||
serve("tools", func() any {
|
||||
return map[string]any{"seats": []map[string]any{
|
||||
{"seat": "mesh-controller", "scope": "mesh", "tools": []map[string]any{
|
||||
{"name": "nodes", "description": "Every machine the mesh knows.", "input": map[string]any{}}}},
|
||||
{"seat": "node-shelf", "scope": "node", "tools": []map[string]any{
|
||||
{"name": "list", "description": "what is on the shelf", "input": map[string]any{}},
|
||||
{"name": "clear", "description": "take it all off", "input": map[string]any{}}}},
|
||||
}}
|
||||
})
|
||||
serve("nodes", func() any {
|
||||
return out("bench 3m ago converged 1f2e\ndesk here converged 9a8b\n")
|
||||
return out("the bus's user list leaves out 2 user(s)\n" +
|
||||
`[{"name":"bench","heard":"3m ago","mode":"converged","id":"1f2e"},{"name":"desk","heard":"here","mode":"converged","id":"9a8b"}]`)
|
||||
})
|
||||
serve("seats", func() any {
|
||||
return out("a warning the command printed first\n" + `{
|
||||
"seats": [
|
||||
{"seat": "mesh-controller", "scope": "mesh", "decision": "x", "holders": [{"module": "mesh-controller", "node": "bench"}]},
|
||||
{"seat": "node-shelf", "scope": "node", "decision": "y", "holders": [{"module": "beta", "node": "desk"}]}
|
||||
]
|
||||
}`)
|
||||
})
|
||||
gammaOn := "nothing"
|
||||
var gammaOn atomic.Value
|
||||
gammaOn.Store("[]")
|
||||
serve("modules", func() any {
|
||||
return out(fmt.Sprintf("alpha 1 built 1a2b3c4d on desk\n needs container-runtime\n"+
|
||||
"beta 1 built 1a2b3c4d on desk\n"+
|
||||
"gamma 1 built 1a2b3c4d on %s\n", gammaOn))
|
||||
return out(fmt.Sprintf(`[{"module":"alpha","on":["desk"],"tools":true},{"module":"beta","on":["desk"],"tools":true},`+
|
||||
`{"module":"gamma","on":%s,"tools":true},{"module":"delta","on":["desk"],"tools":false},`+
|
||||
`{"module":"epsilon","on":["bench"],"tools":true}]`, gammaOn.Load().(string)))
|
||||
})
|
||||
catalogue.Flush()
|
||||
stopAnn, err := announce.Serve(controller, announce.Service{Name: "mesh-controller", ID: "bench"}, func() []announce.Endpoint {
|
||||
return []announce.Endpoint{{Kind: announce.KindSeat, Module: "mesh-controller", Seat: "mesh-controller", Scope: "mesh",
|
||||
Tool: "nodes", Node: "bench", Description: "Every machine the mesh knows.", Schema: json.RawMessage(`{}`),
|
||||
Subject: "mesh.seat.mesh-controller.tool.nodes"}}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(stopAnn)
|
||||
controller.Flush()
|
||||
watcher.Flush()
|
||||
|
||||
up, err := Serve(NewSurface(nodeTools, "desk.node-tools"), "127.0.0.1:0")
|
||||
if err != nil {
|
||||
@@ -132,6 +134,11 @@ func TestTheMeshsToolsAreFoundByAddress(t *testing.T) {
|
||||
!strings.Contains(overview, `"bench"`) || !strings.Contains(overview, `"desk"`) {
|
||||
t.Errorf("overview: %s", overview)
|
||||
}
|
||||
// Silence is named only where tools should have answered: epsilon declares tools on bench and
|
||||
// nothing there announced it; delta declares none and is never a name.
|
||||
if !strings.Contains(overview, "epsilon on bench") || strings.Contains(overview, "delta") {
|
||||
t.Errorf("not answering: %s", overview)
|
||||
}
|
||||
machine, isErr := call(t, endpoint, "mesh_machine", map[string]any{"node": "desk"})
|
||||
if isErr || !strings.Contains(machine, "desk/node-shelf.list") || !strings.Contains(machine, "desk/beta.three") ||
|
||||
!strings.Contains(machine, "desk/alpha.one") {
|
||||
@@ -181,7 +188,7 @@ func TestTheMeshsToolsAreFoundByAddress(t *testing.T) {
|
||||
t.Fatalf("gamma was found before it served: %s", got)
|
||||
}
|
||||
mesh.Issue(t, mt.MembershipOf("gamma", "desk", false, nil))
|
||||
gammaOn = "desk"
|
||||
gammaOn.Store(`["desk"]`)
|
||||
late := connect(t, "node-tools", "desk")
|
||||
stopLate, err := runtime.Run(late, []runtime.Served{{Module: "gamma", Entrypoints: []string{mt.Fixture("env-gamma.serve.mjs")}}},
|
||||
nil, (&mt.Logs{}).Logf)
|
||||
@@ -201,22 +208,21 @@ func TestTheMeshsToolsAreFoundByAddress(t *testing.T) {
|
||||
t.Errorf("a module that arrived later was not found: %s", found)
|
||||
}
|
||||
|
||||
if n := asked.Load(); n != 0 {
|
||||
t.Errorf("discovery asked a roster or a module's tools %d time(s); it asks the bus", n)
|
||||
}
|
||||
|
||||
// The old names still answer, unannounced.
|
||||
if got, isErr := call(t, endpoint, "alpha.one", nil); isErr || !strings.Contains(got, `"alpha": 1`) {
|
||||
t.Errorf("an old name: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheControllersPrintedListsAreRead(t *testing.T) {
|
||||
machines := machinesIn("the bus's user list leaves out 2 user(s)\nace 2m ago converged 0c1d\n" +
|
||||
"g14 here converged 77aa\nnovox 5s ago adopted 3e4f\n")
|
||||
if got := strings.Join(machines, ","); got != "ace,g14,novox" {
|
||||
t.Errorf("machines: %s", got)
|
||||
func TestTheControllersRecordsAreReadAsJSON(t *testing.T) {
|
||||
if got := jsonIn("a warning printed first\n[{\"name\":\"ace\"}]"); got != `[{"name":"ace"}]` {
|
||||
t.Errorf("an array after a warning: %q", got)
|
||||
}
|
||||
on := assignmentsIn("baserow 1 built 7c800705 on ace\n requires postgres-database\n" +
|
||||
"confluence 1 built 7c800705 on nothing\n" +
|
||||
"mesh-wireguard 1 with the control plane on ace, g14, novox, shanks\n")
|
||||
if strings.Join(on["baserow"], ",") != "ace" || len(on["confluence"]) != 0 || strings.Join(on["mesh-wireguard"], ",") != "ace,g14,novox,shanks" {
|
||||
t.Errorf("assignments: %v", on)
|
||||
if got := jsonIn(`{"seats":[]}`); got != `{"seats":[]}` {
|
||||
t.Errorf("an object: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,19 +5,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
||||
)
|
||||
|
||||
const (
|
||||
catalogueModules = "mesh-catalog.catalog_modules"
|
||||
seatTools = "seat:mesh-controller.tools"
|
||||
toolsVerb = "tools"
|
||||
)
|
||||
|
||||
// Tool is a tool as its module — or, for a role's tool, the mesh's records — describes it.
|
||||
type Tool struct {
|
||||
Module string
|
||||
@@ -67,107 +59,14 @@ func toolKey(name string, seats Seats) string {
|
||||
return name
|
||||
}
|
||||
|
||||
// toolsOn asks the mesh what tools it has: the catalogue which modules it holds, each module what it
|
||||
// serves, the controller's seat every role's tools — at once, so a restarting control plane hides
|
||||
// nothing else.
|
||||
// toolsOn is the flat catalogue (MESH_CONSOLE_FLAT=1): what announced itself on the bus, every
|
||||
// tool and seat verb, and the assignments with tools that did not (novox/hq ADR 0197).
|
||||
func toolsOn(conn *bus.Conn) (*Listing, error) {
|
||||
type rolesAnswer struct {
|
||||
Seats []struct {
|
||||
Seat string `json:"seat"`
|
||||
Scope string `json:"scope"`
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
} `json:"tools"`
|
||||
} `json:"seats"`
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
var roles *rolesAnswer
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if got, err := conn.Ask(seatTools, map[string]any{}, ""); err == nil {
|
||||
var r rolesAnswer
|
||||
if json.Unmarshal(got.Result, &r) == nil {
|
||||
roles = &r
|
||||
}
|
||||
}
|
||||
}()
|
||||
answered, err := conn.Ask(catalogueModules, map[string]any{}, "")
|
||||
x, err := indexOn(conn)
|
||||
if err != nil {
|
||||
wg.Wait()
|
||||
return nil, err
|
||||
}
|
||||
var held struct {
|
||||
Modules []struct {
|
||||
Module string `json:"module"`
|
||||
} `json:"modules"`
|
||||
}
|
||||
_ = json.Unmarshal(answered.Result, &held)
|
||||
names := make([]string, 0, len(held.Modules))
|
||||
for _, m := range held.Modules {
|
||||
if m.Module != "" {
|
||||
names = append(names, m.Module)
|
||||
}
|
||||
}
|
||||
type outcome struct {
|
||||
ok bool
|
||||
answer struct {
|
||||
Tools *[]struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
Subjects []string `json:"subjects"`
|
||||
} `json:"tools"`
|
||||
Failed *string `json:"failed"`
|
||||
}
|
||||
}
|
||||
outcomes := make([]outcome, len(names))
|
||||
for i, module := range names {
|
||||
wg.Add(1)
|
||||
go func(i int, module string) {
|
||||
defer wg.Done()
|
||||
got, err := conn.Ask(module+"."+toolsVerb, map[string]any{}, "")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if json.Unmarshal(got.Result, &outcomes[i].answer) == nil {
|
||||
outcomes[i].ok = true
|
||||
}
|
||||
}(i, module)
|
||||
}
|
||||
wg.Wait()
|
||||
l := &Listing{Tools: []Tool{}, NotAnswering: []string{}}
|
||||
if roles != nil {
|
||||
for _, s := range roles.Seats {
|
||||
for _, t := range s.Tools {
|
||||
l.Tools = append(l.Tools, Tool{Module: s.Seat, Name: t.Name, Description: t.Description,
|
||||
Input: t.Input, Seat: true, Scope: s.Scope})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
l.NotAnswering = append(l.NotAnswering, "mesh-controller (seat)")
|
||||
}
|
||||
for i, module := range names {
|
||||
o := outcomes[i]
|
||||
switch {
|
||||
case o.ok && o.answer.Failed != nil:
|
||||
l.NotAnswering = append(l.NotAnswering, fmt.Sprintf("%s (its tools bundle failed to load: %s)", module, *o.answer.Failed))
|
||||
case o.ok && o.answer.Tools != nil:
|
||||
for _, t := range *o.answer.Tools {
|
||||
l.Tools = append(l.Tools, Tool{Module: module, Name: t.Name, Description: t.Description,
|
||||
Input: t.Input, Subjects: t.Subjects})
|
||||
}
|
||||
default:
|
||||
l.NotAnswering = append(l.NotAnswering, module)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(l.Tools, func(i, j int) bool {
|
||||
return l.Tools[i].Module+"."+l.Tools[i].Name < l.Tools[j].Module+"."+l.Tools[j].Name
|
||||
})
|
||||
sort.Strings(l.NotAnswering)
|
||||
return l, nil
|
||||
return x.Listing, nil
|
||||
}
|
||||
|
||||
// callTool calls `<module>.<tool>[@<node>]`, on the subject the listing names for it when it names one.
|
||||
|
||||
@@ -55,20 +55,13 @@ func TestTheConsoleListsAndCallsOverHTTP(t *testing.T) {
|
||||
}
|
||||
defer stop()
|
||||
|
||||
catalogue := connect(t, "mesh-catalog", "")
|
||||
stopCat, _ := catalogue.Handle("catalog_modules", func(json.RawMessage) (any, error) {
|
||||
return map[string]any{"modules": []map[string]string{{"module": "alpha"}, {"module": "beta"}, {"module": "ghost"}}}, nil
|
||||
})
|
||||
defer stopCat()
|
||||
// The controller's records (ADR 0197): ghost declares tools on desk and nothing announces it.
|
||||
controller := connect(t, "mesh-controller", "")
|
||||
stopSeat, _ := controller.HandleSubject("mesh.seat.mesh-controller.tool.tools", func(json.RawMessage) (any, error) {
|
||||
return map[string]any{"seats": []map[string]any{{"seat": "node-shelf", "scope": "node", "tools": []map[string]any{
|
||||
{"name": "list", "description": "what is on the shelf", "input": map[string]any{}},
|
||||
{"name": "clear", "description": "take it all off", "input": map[string]any{}},
|
||||
}}}}, nil
|
||||
stopSeat, _ := controller.HandleSubject("mesh.seat.mesh-controller.tool.modules", func(json.RawMessage) (any, error) {
|
||||
return map[string]any{"ok": true, "output": `[{"module":"alpha","on":["desk"],"tools":true},` +
|
||||
`{"module":"beta","on":["desk"],"tools":true},{"module":"ghost","on":["desk"],"tools":true}]`}, nil
|
||||
})
|
||||
defer stopSeat()
|
||||
catalogue.Flush()
|
||||
controller.Flush()
|
||||
|
||||
flat := NewSurface(nodeTools, "desk.node-tools")
|
||||
@@ -92,7 +85,7 @@ func TestTheConsoleListsAndCallsOverHTTP(t *testing.T) {
|
||||
if got := strings.Join(names, ","); got != "alpha.one,alpha.two,beta.five,beta.four,beta.three,node-shelf.clear,node-shelf.list" {
|
||||
t.Errorf("listed %s", got)
|
||||
}
|
||||
if got := listed["_meta"].(map[string]any)["notAnswering"]; len(got.([]any)) != 1 || got.([]any)[0] != "ghost" {
|
||||
if got := listed["_meta"].(map[string]any)["notAnswering"]; len(got.([]any)) != 1 || got.([]any)[0] != "ghost on desk" {
|
||||
t.Errorf("not answering: %v", got)
|
||||
}
|
||||
for _, x := range listed["tools"].([]any) {
|
||||
|
||||
@@ -122,7 +122,7 @@ func (s *Surface) Handle(r Request) *Reply {
|
||||
}
|
||||
l, err := s.listing()
|
||||
if err != nil {
|
||||
return refuse(r.ID, -32603, whyItFailed(catalogueModules, err))
|
||||
return refuse(r.ID, -32603, "the mesh's discovery failed: "+err.Error())
|
||||
}
|
||||
tools := make([]map[string]any, 0, len(l.Tools))
|
||||
for _, t := range l.Tools {
|
||||
|
||||
Reference in New Issue
Block a user