package console import ( "encoding/json" "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 Name string Description string Input json.RawMessage Seat bool Scope string Subjects []string } // Listing is what the mesh could say about its tools; silence is named, never dropped (design 34 §3). type Listing struct { Tools []Tool NotAnswering []string } // Seats is the seats and their verbs, so `.` resolves to the role. type Seats map[string]map[string]bool func seatsIn(l *Listing) Seats { out := Seats{} for _, t := range l.Tools { if !t.Seat { continue } if out[t.Module] == nil { out[t.Module] = map[string]bool{} } out[t.Module][t.Name] = true } return out } // toolKey is the key a call uses: a role's when the prefix is a seat declaring that verb. func toolKey(name string, seats Seats) string { if strings.HasPrefix(name, "seat:") { return name } dot := strings.Index(name, ".") if dot < 0 { return name } if seats[name[:dot]][name[dot+1:]] { return "seat:" + name } 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. 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{}, "") 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 } // callTool calls `.[@]`, on the subject the listing names for it when it names one. func callTool(conn *bus.Conn, key string, args any, seats Seats, l *Listing) (bus.Answered, error) { name, node, _ := strings.Cut(key, "@") if !strings.Contains(name, ".") { return bus.Answered{}, fmt.Errorf("%q does not name a tool: write ., as `mesh tools` lists "+ "them, or .@ for the instance on one machine", key) } resolved := toolKey(name, seats) if node != "" { resolved += "@" + node } return conn.Ask(resolved, args, subjectListed(name, node, l)) } func subjectListed(name, node string, l *Listing) string { if l == nil { return "" } dot := strings.Index(name, ".") module, tool := name[:dot], name[dot+1:] for _, t := range l.Tools { if t.Module == module && t.Name == tool && !t.Seat { if len(t.Subjects) == 0 { return "" } if node == "" { return t.Subjects[0] } for _, s := range t.Subjects { if strings.HasSuffix(s, "."+node) { return s } } return "" } } return "" } var ( noResponders = regexp.MustCompile(`(?i)no responders|503`) refused = regexp.MustCompile(`(?i)permissions violation|authorization`) timedOut = regexp.MustCompile(`(?i)timeout`) ) // whyItFailed says why a call failed, so the remedy is in the words. func whyItFailed(key string, err error) string { if err == nil { err = errors.New("failed") } msg := err.Error() switch { case noResponders.MatchString(msg): extra := "" if strings.HasPrefix(key, "seat:") { extra = ", or nothing holds that seat" } return "nothing serves " + key + ". The module may not be assigned to any machine, or it is down" + extra + " — `mesh tools` lists what answered." case refused.MatchString(msg): return "this account may not call " + key + ". What it may call was fixed when it was issued — a " + "person's by `operator issue`, the console's by its manifest." case timedOut.MatchString(msg): return key + " did not answer in time. Something is serving it, so this is the tool being slow " + "rather than absent." } return key + " failed: " + msg }