Merge pull request 'Find a tool by the shell command it replaces, best first (hq ADR 0245)' (#21) from feat/search-finds-what-a-verb-replaces into main
This commit was merged in pull request #21.
This commit is contained in:
@@ -68,6 +68,13 @@ address) and `mesh_runtimes` (which runtimes answered discovery: per runtime its
|
||||
answer took, its size in bytes, how many modules and tools it announced, whether it was shortened to
|
||||
fit the bus, when it was last heard — and who was expected and not heard).
|
||||
|
||||
`mesh_search` also finds a tool by the shell command it replaces (novox/hq ADR 0245): each seat verb and
|
||||
module tool says what it replaces in the controller's records (`replaces`, read from the controller's
|
||||
`tools` and `modules` answers), so `journalctl -u x`, `systemctl status x` or `docker ps` puts the verb
|
||||
for it first, and says the command it matched as `instead_of`. A word also matches through a short table
|
||||
of the words agents use for the mesh's (`logs` finds the journal). Best first: a replaced command, then
|
||||
words in a name or a replaced command, then words in a description; seats before modules on a tie.
|
||||
|
||||
Discovery asks the bus (ADR 0197): every runtime answers the NATS services protocol's `$SRV.PING` and
|
||||
`$SRV.INFO` with what it serves at that moment, and the console reads where the controller's records
|
||||
place each module. The console waits at least 750 ms, and up to 5 s for every runtime that answered
|
||||
|
||||
+3
-2
@@ -30,8 +30,9 @@ bundleless=""
|
||||
if ! command -v node >/dev/null 2>&1 || [ ! -d node_modules/@novox/mesh-sdk ]; then
|
||||
echo "NOT TESTED HERE: internal/runtime and internal/console launch bundles that need node and @novox/mesh-sdk"
|
||||
packages=$(printf '%s\n' "$packages" | grep -v -e '/internal/runtime$' -e '/internal/console$')
|
||||
# What of the console launches no bundle runs anyway: how an address resolves (novox/hq issue 287).
|
||||
bundleless='^TestASeatAndAModuleOfOneNameAreEachReached$'
|
||||
# What of the console launches no bundle runs anyway: how an address resolves (novox/hq issue 287),
|
||||
# and how a search ranks (novox/hq ADR 0245).
|
||||
bundleless='^(TestASeatAndAModuleOfOneNameAreEachReached|TestSearch.*|TestASearchByCommandSaysWhatItReplaces)$'
|
||||
fi
|
||||
if command -v gcc >/dev/null 2>&1; then
|
||||
CGO_ENABLED=1 go test -race -count=1 $packages
|
||||
|
||||
@@ -67,9 +67,10 @@ func discovery() []map[string]any {
|
||||
{"name": verbMachine, "inputSchema": obj(map[string]any{"node": str("the machine, as mesh_overview names it")}, "node"),
|
||||
"description": "One machine: the seats it holds with their verbs, and the modules assigned to it with their tools — " +
|
||||
"each with the address to describe or call it by. " + grammar},
|
||||
{"name": verbSearch, "inputSchema": obj(map[string]any{"query": str("words to find in tool names and descriptions, e.g. `postgres databases`")}, "query"),
|
||||
{"name": verbSearch, "inputSchema": obj(map[string]any{"query": str("words to find in tool names and descriptions, e.g. `postgres databases` — or the shell command you would have run, e.g. `journalctl -u x`, `docker ps`")}, "query"),
|
||||
"description": "Find tools anywhere in the mesh by words: every match's address and a line of what it does, across the " +
|
||||
"mesh's seats, the machines' seats and every module on every machine. " + grammar},
|
||||
"mesh's seats, the machines' seats and every module on every machine, best first. A shell command finds the tool " +
|
||||
"that replaces it — search before reaching for ssh, journalctl, systemctl or docker. " + grammar},
|
||||
{"name": verbDescribe, "inputSchema": obj(map[string]any{"address": str("the tool's address")}, "address"),
|
||||
"description": "What one tool does and the arguments it takes, as a JSON schema. The machine is in the address, " +
|
||||
"never an argument. " + grammar},
|
||||
@@ -130,6 +131,9 @@ type index struct {
|
||||
// what it serves in time, or it answered before and not now. An address it might answer is never
|
||||
// called missing while it is here (2026-10-05).
|
||||
Unheard []unheard
|
||||
// Replaces is what each verb and tool replaces, from the controller's records (novox/hq ADR 0245):
|
||||
// `seat:<seat>.<verb>` and `<module>.<tool>`.
|
||||
Replaces map[string][]string
|
||||
}
|
||||
|
||||
// unheard is one runtime discovery did not hear in full, and why.
|
||||
@@ -221,6 +225,8 @@ type recordedModule struct {
|
||||
Module string `json:"module"`
|
||||
On []string `json:"on"`
|
||||
Tools bool `json:"tools"`
|
||||
// Replaces is what each of its own tools replaces (novox/hq ADR 0245).
|
||||
Replaces map[string][]string `json:"replaces,omitempty"`
|
||||
}
|
||||
|
||||
// recordedMachine is a machine as the controller's records hold it (`node list --json`).
|
||||
@@ -234,9 +240,18 @@ type recordedMachine struct {
|
||||
func indexOn(conn *bus.Conn) (*index, error) {
|
||||
var wg sync.WaitGroup
|
||||
var nodesOut, modulesOut string
|
||||
wg.Add(2)
|
||||
var toolsAnswer json.RawMessage
|
||||
wg.Add(3)
|
||||
go func() { defer wg.Done(); nodesOut, _ = controllerOutput(conn, "nodes") }()
|
||||
go func() { defer wg.Done(); modulesOut, _ = controllerOutput(conn, "modules") }()
|
||||
// The seats' verbs as the records define them, for what each replaces (novox/hq ADR 0245): answered
|
||||
// in-process from the records, never a command.
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if got, err := conn.Ask("seat:mesh-controller.tools", map[string]any{}, ""); err == nil {
|
||||
toolsAnswer = got.Result
|
||||
}
|
||||
}()
|
||||
d, err := announce.Gather(conn)
|
||||
wg.Wait()
|
||||
if err != nil {
|
||||
@@ -258,7 +273,9 @@ func indexOn(conn *bus.Conn) (*index, error) {
|
||||
// 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 {
|
||||
recordedOK := json.Unmarshal([]byte(jsonIn(modulesOut)), &recorded) == nil
|
||||
x.Replaces = replacesOf(toolsAnswer, recorded)
|
||||
if recordedOK {
|
||||
for _, m := range recorded {
|
||||
if !m.Tools {
|
||||
continue
|
||||
@@ -803,70 +820,10 @@ func (s *Surface) discover(name string, args map[string]any) map[string]any {
|
||||
case verbSearch:
|
||||
query := strings.ToLower(str("query"))
|
||||
if query == "" {
|
||||
return failure("mesh_search needs `query`: words to find, e.g. `postgres databases`")
|
||||
}
|
||||
words := strings.Fields(query)
|
||||
type hit struct {
|
||||
Address string `json:"address"`
|
||||
Does string `json:"does"`
|
||||
Also string `json:"also,omitempty"`
|
||||
}
|
||||
var hits []hit
|
||||
matches := func(parts ...string) bool {
|
||||
hay := strings.ToLower(strings.Join(parts, " "))
|
||||
for _, w := range words {
|
||||
if !strings.Contains(hay, w) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
for _, st := range x.Seats {
|
||||
for _, v := range st.Verbs {
|
||||
if !matches(st.Seat, v.Name, v.Description) {
|
||||
continue
|
||||
}
|
||||
if st.Scope == "node" {
|
||||
on := nodesOf(st.Holders)
|
||||
first := "<node>"
|
||||
also := ""
|
||||
if len(on) > 0 {
|
||||
first = on[0]
|
||||
if len(on) > 1 {
|
||||
also = "also on " + strings.Join(on[1:], ", ")
|
||||
}
|
||||
}
|
||||
hits = append(hits, hit{first + "/" + st.Seat + "." + v.Name, firstLine(v.Description), also})
|
||||
} else {
|
||||
hits = append(hits, hit{st.Seat + "." + v.Name, firstLine(v.Description), ""})
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
if !matches(m.Module, t.Name, t.Description) {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case m.Interchangeable:
|
||||
hits = append(hits, hit{m.Module + "." + t.Name, firstLine(t.Description), "any instance; on " + orNobody(m.On)})
|
||||
case len(m.On) == 0:
|
||||
hits = append(hits, hit{"<node>/" + m.Module + "." + t.Name, firstLine(t.Description), "the mesh places it on no machine"})
|
||||
default:
|
||||
also := ""
|
||||
if len(m.On) > 1 {
|
||||
also = "also on " + strings.Join(m.On[1:], ", ")
|
||||
}
|
||||
hits = append(hits, hit{m.On[0] + "/" + m.Module + "." + t.Name, firstLine(t.Description), also})
|
||||
}
|
||||
}
|
||||
return failure("mesh_search needs `query`: words to find, e.g. `postgres databases`, or the command you would " +
|
||||
"have run, e.g. `journalctl` or `docker ps`")
|
||||
}
|
||||
hits := x.search(query)
|
||||
out := map[string]any{"matches": hits}
|
||||
if len(hits) > searchCap {
|
||||
out["matches"] = hits[:searchCap]
|
||||
@@ -874,7 +831,8 @@ func (s *Surface) discover(name string, args map[string]any) map[string]any {
|
||||
}
|
||||
if len(hits) == 0 {
|
||||
out["matches"] = []hit{}
|
||||
out["hint"] = "nothing matched every word; try fewer words, or mesh_overview and mesh_machine to browse"
|
||||
out["hint"] = "nothing matched every word; try fewer words, or mesh_overview and mesh_machine to browse. " +
|
||||
"If no tool does it, one is created in the module that owns it, on its seat — never worked around (novox/hq ADR 0245)"
|
||||
}
|
||||
if len(x.Unheard) > 0 {
|
||||
out["incomplete"] = "discovery did not hear in full from " + sayUnheard(x.Unheard) +
|
||||
@@ -895,8 +853,15 @@ func (s *Surface) discover(name string, args map[string]any) map[string]any {
|
||||
if description == "" {
|
||||
description = t.Name
|
||||
}
|
||||
return answerText(map[string]any{"address": t.Address, "description": description,
|
||||
"arguments": schemaAsPassed(t)})
|
||||
described := map[string]any{"address": t.Address, "description": description, "arguments": schemaAsPassed(t)}
|
||||
key := t.Tool.Module + "." + t.Tool.Name
|
||||
if t.Seat {
|
||||
key = "seat:" + key
|
||||
}
|
||||
if r := x.Replaces[key]; len(r) > 0 {
|
||||
described["instead_of"] = r
|
||||
}
|
||||
return answerText(described)
|
||||
|
||||
case verbCall:
|
||||
t, err := resolve(x, str("address"))
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package console
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/announce"
|
||||
)
|
||||
|
||||
// meshToSearch is a mesh as discovery and the controller's records describe it: the service manager's
|
||||
// verbs on two machines, the controller's own, a docker module with its tools, and what each replaces
|
||||
// as the records say it — the seats' from `tools`, the module's from `modules`.
|
||||
func meshToSearch(t *testing.T) *index {
|
||||
t.Helper()
|
||||
var endpoints []announce.Endpoint
|
||||
verbs := map[string]string{
|
||||
"units": "The units the service manager knows in a scope, each with its load, active and sub state.",
|
||||
"status": "One unit as the service manager sees it now: its states, whether it starts at boot.",
|
||||
"restart": "Restart one unit.",
|
||||
"journal": "The last lines of one unit's journal (at most 2000).",
|
||||
}
|
||||
for _, node := range []string{"anchor", "laptop"} {
|
||||
for verb, does := range verbs {
|
||||
endpoints = append(endpoints, announce.Endpoint{Kind: announce.KindSeat, Module: "systemd", Seat: "node-service-manager",
|
||||
Tool: verb, Scope: "node", Node: node, Description: does,
|
||||
Subject: "mesh.seat.node-service-manager.tool." + verb + "." + node})
|
||||
}
|
||||
}
|
||||
for verb, does := range map[string]string{
|
||||
"node": "What one machine reported it can do, what it is assigned, and why.",
|
||||
"nodes": "Every machine the mesh knows, with whether it is converged or adopted.",
|
||||
} {
|
||||
endpoints = append(endpoints, announce.Endpoint{Kind: announce.KindSeat, Module: "mesh-controller", Seat: "mesh-controller",
|
||||
Tool: verb, Scope: "mesh", Description: does, Subject: "mesh.seat.mesh-controller.tool." + verb})
|
||||
}
|
||||
for tool, does := range map[string]string{
|
||||
"docker_list": "Every container on this machine: name, image, state, ports.",
|
||||
"docker_logs": "The last lines a container wrote.",
|
||||
"docker_secrets_in_logs": "Whether a container's logs hold a secret.",
|
||||
"docker_restart": "Restart one container.",
|
||||
} {
|
||||
endpoints = append(endpoints, announce.Endpoint{Kind: announce.KindTool, Module: "docker", Tool: tool, Node: "anchor",
|
||||
Description: does, Subject: "mesh.mod.docker.tool." + tool + ".anchor"})
|
||||
}
|
||||
x, _, _ := indexOfEndpoints(endpoints)
|
||||
tools := json.RawMessage(`{"seats":[{"seat":"node-service-manager","scope":"node","tools":[
|
||||
{"name":"units","replaces":["systemctl list-units","systemctl --failed"]},
|
||||
{"name":"status","replaces":["systemctl status","systemctl is-active"]},
|
||||
{"name":"restart","replaces":["systemctl restart"]},
|
||||
{"name":"journal","replaces":["journalctl"]}]},
|
||||
{"seat":"mesh-controller","scope":"mesh","tools":[{"name":"node","replaces":["hostnamectl","uptime"]},{"name":"nodes"}]}]}`)
|
||||
x.Replaces = replacesOf(tools, []recordedModule{{Module: "docker", On: []string{"anchor"}, Tools: true,
|
||||
Replaces: map[string][]string{"docker_list": {"docker ps"}, "docker_logs": {"docker logs"}, "docker_restart": {"docker restart"}}}})
|
||||
return x
|
||||
}
|
||||
|
||||
// **The words an agent has find the verb first** (novox/hq ADR 0245): the command it would have run, or
|
||||
// the word it would have used, puts the mesh's tool for it at the top.
|
||||
func TestSearchFindsTheToolThatReplacesACommandFirst(t *testing.T) {
|
||||
x := meshToSearch(t)
|
||||
for query, want := range map[string]string{
|
||||
"journalctl": "anchor/node-service-manager.journal",
|
||||
"journalctl -u mesh-controller -n 50": "anchor/node-service-manager.journal",
|
||||
"sudo journalctl -fu sshd": "anchor/node-service-manager.journal",
|
||||
"/usr/bin/journalctl": "anchor/node-service-manager.journal",
|
||||
"logs": "anchor/node-service-manager.journal",
|
||||
"systemctl status": "anchor/node-service-manager.status",
|
||||
"systemctl status nats.service": "anchor/node-service-manager.status",
|
||||
"systemctl restart mesh-controller": "anchor/node-service-manager.restart",
|
||||
"systemctl --failed": "anchor/node-service-manager.units",
|
||||
"docker ps": "anchor/docker.docker_list",
|
||||
"docker ps -a": "anchor/docker.docker_list",
|
||||
"docker logs": "anchor/docker.docker_logs",
|
||||
"docker logs --tail 100 nats": "anchor/docker.docker_logs",
|
||||
"docker restart nats": "anchor/docker.docker_restart",
|
||||
"container logs": "anchor/docker.docker_logs",
|
||||
"uptime": "mesh-controller.node",
|
||||
} {
|
||||
hits := x.search(query)
|
||||
if len(hits) == 0 {
|
||||
t.Errorf("%q found nothing; want %s first", query, want)
|
||||
continue
|
||||
}
|
||||
if hits[0].Address != want {
|
||||
var got []string
|
||||
for _, h := range hits {
|
||||
got = append(got, h.Address)
|
||||
}
|
||||
t.Errorf("%q found %s first; want %s (all: %s)", query, hits[0].Address, want, strings.Join(got, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A match on a replaced command says which command it replaced, and the most specific one wins.
|
||||
func TestASearchByCommandSaysWhatItReplaces(t *testing.T) {
|
||||
x := meshToSearch(t)
|
||||
hits := x.search("systemctl status sshd")
|
||||
if hits[0].InsteadOf != "systemctl status" {
|
||||
t.Errorf("the match does not say what it replaces: %+v", hits[0])
|
||||
}
|
||||
if hits[0].Also != "also on laptop" {
|
||||
t.Errorf("a node seat's verb does not say its other machines: %+v", hits[0])
|
||||
}
|
||||
// `logs` also finds the container's logs, after the journal — both are answers.
|
||||
var docker bool
|
||||
for _, h := range x.search("logs")[:3] {
|
||||
docker = docker || h.Address == "anchor/docker.docker_logs"
|
||||
}
|
||||
if !docker {
|
||||
t.Errorf("`logs` does not find docker_logs among the first: %+v", x.search("logs"))
|
||||
}
|
||||
}
|
||||
|
||||
// What search did before stays: every word in a name or description, and nothing for a word nothing has.
|
||||
// A synonym is a word, never part of one: `logs` matches through `log`, and finds no `login`.
|
||||
func TestSearchStillMatchesEveryWord(t *testing.T) {
|
||||
x := meshToSearch(t)
|
||||
if hits := x.search("machine converged"); len(hits) != 1 || hits[0].Address != "mesh-controller.nodes" {
|
||||
t.Errorf("words in a description: %+v", hits)
|
||||
}
|
||||
if hits := x.search("journalctl kubernetes"); len(hits) != 1 || hits[0].Address != "anchor/node-service-manager.journal" {
|
||||
t.Errorf("a command with arguments nothing has is still that command: %+v", hits)
|
||||
}
|
||||
if hits := x.search("kubernetes"); len(hits) != 0 {
|
||||
t.Errorf("a word nothing has matched: %+v", hits)
|
||||
}
|
||||
x.Modules["shell"] = &moduleInfo{Module: "shell", On: []string{"anchor"}, Tools: []Tool{{Module: "shell", Name: "shell_login",
|
||||
Description: "The account's login shell."}}}
|
||||
for _, h := range x.search("logs") {
|
||||
if h.Address == "anchor/shell.shell_login" {
|
||||
t.Errorf("`logs` found `login` through its synonym `log`: %+v", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A controller older than `replaces` answers without it: search matches names and descriptions as before.
|
||||
func TestSearchWithoutReplacesMatchesAsBefore(t *testing.T) {
|
||||
x := meshToSearch(t)
|
||||
x.Replaces = replacesOf(json.RawMessage(`{"seats":[{"seat":"node-service-manager","tools":[{"name":"journal"}]}]}`),
|
||||
[]recordedModule{{Module: "docker"}})
|
||||
if len(x.Replaces) != 0 {
|
||||
t.Fatalf("replaces from records that have none: %v", x.Replaces)
|
||||
}
|
||||
if hits := x.search("journal"); len(hits) == 0 || hits[0].Address != "anchor/node-service-manager.journal" {
|
||||
t.Errorf("a verb's own name: %+v", hits)
|
||||
}
|
||||
if hits := x.search("docker logs"); len(hits) == 0 || hits[0].Address != "anchor/docker.docker_logs" {
|
||||
t.Errorf("a tool's own name: %+v", hits)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user