package console // Finding a tool by the words an agent already has (novox/hq ADR 0245). // // An agent that wants a unit's log reaches for `journalctl`, and one that wants a container's for `docker // logs`; it searches with those words, or with `logs`. Matched against names and descriptions alone, none // of them found the service manager's `journal` — whose description says "journal", never "logs" or // "journalctl" — and the agent went round the mesh over ssh. So each verb and tool says what it replaces, // and search matches three ways, best first: // // 1. **The query is a command a tool replaces** — `journalctl -u x`, `systemctl status x`, `docker ps`: // every word of the replaced command, in order, among the query's words. The most specific command wins. // 2. **Every word is in the tool's name, its replaced commands or its description**, a word also matching // through a short table of the words agents use for the mesh's (logs ⇄ journal, ps ⇄ list). A word in // the name or a replaced command weighs more than one in the description, and a name the query says // whole more than one it says in part. // 3. Nothing else: a search that matches nothing says so. // // Ties go to a seat — the place tools belong — then to the shorter name, then by address. import ( "encoding/json" "path" "sort" "strings" "unicode" ) // synonyms are the words an agent uses for the mesh's own, each matching the other's (a word, not part // of one: `log` must not match `login`). Kept short: a command is said by the tool that replaces it. var synonyms = map[string][]string{ "logs": {"log", "journal"}, "log": {"logs", "journal"}, "journal": {"logs", "log"}, "ps": {"list", "running"}, "ls": {"list"}, "list": {"ps", "ls"}, "restart": {"restarts"}, "service": {"unit", "units"}, "services": {"unit", "units"}, "unit": {"service"}, "container": {"containers", "docker"}, "firewall": {"filter", "nftables"}, "hosts": {"hostname"}, } // hit is one match as mesh_search answers it. type hit struct { Address string `json:"address"` Does string `json:"does"` // InsteadOf is the command the query matched, when it matched one the tool replaces. InsteadOf string `json:"instead_of,omitempty"` Also string `json:"also,omitempty"` score int } // words are a text's words, lower-cased, split at anything that is not a letter, a digit, `-` or `_` // within a word — so `node-service-manager.journal` is its three names' parts and the whole. func words(s string) []string { var out []string for _, w := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { return !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' || r == '/') }) { out = append(out, w) for _, part := range strings.FieldsFunc(w, func(r rune) bool { return r == '-' || r == '_' || r == '/' }) { if part != w { out = append(out, part) } } } return out } // commandWords are a command line's words as matched against a replaced command: lower-cased, a // program named by path reduced to its name, and what only runs it (`sudo`) left out. func commandWords(line string) []string { var out []string for i, w := range strings.Fields(strings.ToLower(line)) { if i == 0 || strings.HasPrefix(w, "/") { w = path.Base(w) } if w == "sudo" { continue } out = append(out, w) } return out } // inOrder says whether every one of want is among have, in the same order. func inOrder(want, have []string) bool { i := 0 for _, h := range have { if i < len(want) && h == want[i] { i++ } } return len(want) > 0 && i == len(want) } // replacedBy is the replaced command a query names, the most specific of those it matches, or "". func replacedBy(query string, replaces []string) string { q := commandWords(query) best, bestLen := "", 0 for _, r := range replaces { w := commandWords(r) if inOrder(w, q) && len(w) > bestLen { best, bestLen = r, len(w) } } return best } // scoreOf is how well a tool matches the query: 0 for not at all. func scoreOf(query string, qwords []string, name, description string, replaces []string) (int, string) { if cmd := replacedBy(query, replaces); cmd != "" { return 100 + 10*len(commandWords(cmd)), cmd } strong := strings.ToLower(name + " " + strings.Join(replaces, " ")) weak := strings.ToLower(description) strongWords := map[string]bool{} for _, w := range words(strong) { strongWords[w] = true } weakWords := map[string]bool{} for _, w := range words(weak) { weakWords[w] = true } score := 10 for _, w := range qwords { switch { case strings.Contains(strong, w): score += 5 case strings.Contains(weak, w): score++ default: found := 0 for _, s := range synonyms[w] { if strongWords[s] { found = 5 break } if weakWords[s] { found = 1 } } if found == 0 { return 0, "" } score += found } } // A name the query says whole — `docker logs` for docker_logs, not docker_secrets_in_logs — first. own := name[strings.LastIndex(name, ".")+1:] whole := true for _, part := range strings.FieldsFunc(strings.ToLower(own), func(r rune) bool { return r == '_' || r == '-' }) { in := false for _, w := range qwords { in = in || w == part } whole = whole && in } if whole { score += 2 } return score, "" } // search is mesh_search's answer: every match, best first. func (x *index) search(query string) []hit { query = strings.ToLower(strings.TrimSpace(query)) qwords := strings.Fields(query) var hits []hit for _, st := range x.Seats { for _, v := range st.Verbs { replaces := x.Replaces["seat:"+st.Seat+"."+v.Name] score, cmd := scoreOf(query, qwords, st.Seat+"."+v.Name, v.Description, replaces) if score == 0 { continue } h := hit{Does: firstLine(v.Description), InsteadOf: cmd, score: score + 1} // a seat first, on a tie if st.Scope == "node" { on := nodesOf(st.Holders) first := "" if len(on) > 0 { first = on[0] if len(on) > 1 { h.Also = "also on " + strings.Join(on[1:], ", ") } } h.Address = first + "/" + st.Seat + "." + v.Name } else { h.Address = st.Seat + "." + v.Name } hits = append(hits, h) } } names := make([]string, 0, len(x.Modules)) for n := range x.Modules { names = append(names, n) } sort.Strings(names) for _, n := range names { m := x.Modules[n] for _, t := range m.Tools { score, cmd := scoreOf(query, qwords, m.Module+"."+t.Name, t.Description, x.Replaces[m.Module+"."+t.Name]) if score == 0 { continue } h := hit{Does: firstLine(t.Description), InsteadOf: cmd, score: score} switch { case m.Interchangeable: h.Address, h.Also = m.Module+"."+t.Name, "any instance; on "+orNobody(m.On) case len(m.On) == 0: h.Address, h.Also = "/"+m.Module+"."+t.Name, "the mesh places it on no machine" default: h.Address = m.On[0] + "/" + m.Module + "." + t.Name if len(m.On) > 1 { h.Also = "also on " + strings.Join(m.On[1:], ", ") } } hits = append(hits, h) } } // Best first; on a tie the shorter name — the plainer tool — then the address, so an answer never // depends on the order discovery heard things in. sort.SliceStable(hits, func(i, j int) bool { if hits[i].score != hits[j].score { return hits[i].score > hits[j].score } a, b := hits[i].Address[strings.LastIndex(hits[i].Address, ".")+1:], hits[j].Address[strings.LastIndex(hits[j].Address, ".")+1:] if len(a) != len(b) { return len(a) < len(b) } return hits[i].Address < hits[j].Address }) return hits } // replacesOf is what every verb and tool replaces, from the controller's records (novox/hq ADR 0245): // a seat's verbs from its `tools` answer, keyed `seat:.`, and a module's own tools from // its `modules` answer, keyed `.`. A controller older than the field answers neither, // and search matches names and descriptions as before. func replacesOf(toolsAnswer json.RawMessage, modules []recordedModule) map[string][]string { out := map[string][]string{} var seats struct { Seats []struct { Seat string `json:"seat"` Tools []struct { Name string `json:"name"` Replaces []string `json:"replaces"` } `json:"tools"` } `json:"seats"` } if json.Unmarshal(toolsAnswer, &seats) == nil { for _, s := range seats.Seats { for _, t := range s.Tools { if len(t.Replaces) > 0 { out["seat:"+s.Seat+"."+t.Name] = t.Replaces } } } } for _, m := range modules { for tool, r := range m.Replaces { if len(r) > 0 { out[m.Module+"."+tool] = r } } } return out }