A tie went to whichever tool discovery listed first, so docker logs found docker_secrets_in_logs before docker_logs on some runs. A name the query says whole wins, then the shorter name.
273 lines
8.5 KiB
Go
273 lines
8.5 KiB
Go
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 := "<node>"
|
|
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 = "<node>/"+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:<seat>.<verb>`, and a module's own tools from
|
|
// its `modules` answer, keyed `<module>.<tool>`. 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
|
|
}
|