diff --git a/cmd/mesh-controller/modules.go b/cmd/mesh-controller/modules.go index 699ce941..17e412c6 100644 --- a/cmd/mesh-controller/modules.go +++ b/cmd/mesh-controller/modules.go @@ -182,6 +182,9 @@ func moduleCommand(ctx context.Context, args []string) error { Requires []string `json:"requires,omitempty"` Claims []string `json:"claims,omitempty"` Capabilities []string `json:"capabilities,omitempty"` + // Replaces is what each of its own tools replaces (novox/hq ADR 0244), for the console's + // search and the agent's instructions. + Replaces map[string][]string `json:"replaces,omitempty"` } out := make([]listed, 0, len(entries)) for _, e := range entries { @@ -189,7 +192,7 @@ func moduleCommand(ctx context.Context, args []string) error { l := listed{Module: m.Module, Version: m.Version, Built: e.Source.BuiltFrom, Head: e.Source.Head, Current: e.Provided || e.Source.Repository == "" || e.Source.Current(), Provided: e.Provided, On: append([]string{}, e.On...), Provides: m.Offers(), Requires: m.Requires, - Capabilities: m.Capabilities, Tools: declaresTools(m)} + Capabilities: m.Capabilities, Tools: declaresTools(m), Replaces: m.Replaces} for _, c := range m.Claims { l.Claims = append(l.Claims, c.At()+"/"+c.Name) } diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 6236a695..1ccfa402 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -970,9 +970,15 @@ func seatTools() map[string]any { } var tools []map[string]any for _, v := range s.Serves { - tools = append(tools, map[string]any{ + tool := map[string]any{ "name": v.Name, "description": v.Description, "input": v.Input, "output": v.Output, - }) + } + // What the verb is the mesh's way to do (novox/hq ADR 0244): read by the console's search and + // the agent's instructions. + if len(v.Replaces) > 0 { + tool["replaces"] = v.Replaces + } + tools = append(tools, tool) } seats = append(seats, map[string]any{"seat": s.Name, "scope": s.Scope, "tools": tools}) } diff --git a/cmd/mesh-controller/seatverbs_test.go b/cmd/mesh-controller/seatverbs_test.go index 4e8b1c62..42b341d4 100644 --- a/cmd/mesh-controller/seatverbs_test.go +++ b/cmd/mesh-controller/seatverbs_test.go @@ -144,6 +144,23 @@ func TestToolsAnswersTheSeatsRecords(t *testing.T) { if !found { t.Fatal("the mesh-controller seat is not in the listing") } + // And what a verb replaces travels with it (novox/hq ADR 0244): the console and the agent's + // instructions read it from here. + var journal []string + for _, s := range seats { + if s["seat"] != catalogue.ServiceManagerSeat { + continue + } + tools, _ := s["tools"].([]map[string]any) + for _, tool := range tools { + if tool["name"] == "journal" { + journal, _ = tool["replaces"].([]string) + } + } + } + if len(journal) == 0 || journal[0] != "journalctl" { + t.Fatalf("the service manager's journal does not say it replaces journalctl: %v", journal) + } } // A JSON verb's answer is parsed from what the command wrote to standard output alone; a warning it diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index d13d2725..a69b8e98 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -426,6 +426,13 @@ type Manifest struct { // module claiming a seat answers what that seat's protocol promises (novox/hq ADR 0118). Tools []string `json:"tools,omitempty"` + // Replaces says, for a tool of this module's own, the shell commands it is the mesh's way to do — + // `{"docker_logs": ["docker logs"]}` — as a seat's verb says it in its definition (novox/hq ADR + // 0241). A seat's verb carries its own: what a role replaces is the role's, so a module never says it + // for a verb it serves under a claim. The console's search, the agent's instructions and the guard on + // its shell are built from both. + Replaces map[string][]string `json:"replaces,omitempty"` + // Instances says whether this module's instances are the same anywhere — `interchangeable` — // so a call that names no machine may be answered by any of them (novox/hq ADR 0160). A fact // about the software, not about the bus: a stateless web tool says it; a database does not, @@ -1692,6 +1699,7 @@ func ParseManifest(raw []byte) (Manifest, error) { } } problems = append(problems, invokeProblems(m)...) + problems = append(problems, replacesProblems(m)...) problems = append(problems, endpointNameProblems(m)...) problems = append(problems, RouteProblems(m)...) for _, port := range m.Guards { @@ -2306,6 +2314,53 @@ func EndpointPort(m Manifest, name string) (int, bool) { return 0, false } +// MaxReplaced is the longest command a tool may say it replaces: a command's name and the words that +// make it this command, never a script. +const MaxReplaced = 80 + +// replacesProblems judges what a module says its tools replace (novox/hq ADR 0244): each key a tool it +// declares, each entry a command line of one line, short, and said once. +func replacesProblems(m Manifest) []string { + var problems []string + tools := map[string]bool{} + for _, t := range m.Tools { + tools[t] = true + } + keys := make([]string, 0, len(m.Replaces)) + for k := range m.Replaces { + keys = append(keys, k) + } + sort.Strings(keys) + for _, tool := range keys { + if !tools[tool] { + problems = append(problems, fmt.Sprintf("%s says what %q replaces, and declares no such tool: "+ + "a module says it for a tool in its `tools`; a seat's verb says it in the seat's definition "+ + "(novox/hq ADR 0244)", m.Module, tool)) + continue + } + if len(m.Replaces[tool]) == 0 { + problems = append(problems, fmt.Sprintf("%s says %s replaces nothing: leave it out", m.Module, tool)) + } + seen := map[string]bool{} + for _, c := range m.Replaces[tool] { + c = strings.TrimSpace(c) + switch { + case c == "": + problems = append(problems, fmt.Sprintf("%s: %s replaces an empty command", m.Module, tool)) + case strings.ContainsAny(c, "\n\r"): + problems = append(problems, fmt.Sprintf("%s: %s replaces %q, which is more than one line", m.Module, tool, c)) + case len(c) > MaxReplaced: + problems = append(problems, fmt.Sprintf("%s: %s replaces %q, longer than %d characters: "+ + "the command's name and the words that make it this command", m.Module, tool, c, MaxReplaced)) + case seen[c]: + problems = append(problems, fmt.Sprintf("%s: %s replaces %q twice", m.Module, tool, c)) + } + seen[c] = true + } + } + return problems +} + // invokeProblems judges what a module says it calls (novox/hq ADR 0152). // // Refused here, in the manifest's words, rather than at the next composition of the bus's user diff --git a/internal/catalogue/replaces_test.go b/internal/catalogue/replaces_test.go new file mode 100644 index 00000000..37b20a20 --- /dev/null +++ b/internal/catalogue/replaces_test.go @@ -0,0 +1,93 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// The verbs an agent works around most say what they replace (novox/hq ADR 0244): the journal, a +// restart, a unit's status, the hosts file — each the command the agent would otherwise type over ssh. +func TestTheVerbsWorkedAroundSayWhatTheyReplace(t *testing.T) { + want := map[string]string{ + ServiceManagerSeat + ".journal": "journalctl", + ServiceManagerSeat + ".restart": "systemctl restart", + ServiceManagerSeat + ".status": "systemctl status", + "node-hostname.add": "edit /etc/hosts", + ControllerSeatName + ".node": "hostnamectl", + } + for _, s := range DefaultSeats() { + for _, v := range s.Serves { + cmd, ok := want[s.Name+"."+v.Name] + if !ok { + continue + } + delete(want, s.Name+"."+v.Name) + found := false + for _, r := range v.Replaces { + found = found || r == cmd + } + if !found { + t.Errorf("%s.%s does not say it replaces %q: %v", s.Name, v.Name, cmd, v.Replaces) + } + } + } + for verb := range want { + t.Errorf("%s is not a verb of the compiled seats", verb) + } +} + +// Every command a seat's verb replaces is one short line, said once — what the guard and the search match. +func TestEveryReplacedCommandIsOneShortLine(t *testing.T) { + for _, s := range DefaultSeats() { + for _, v := range s.Serves { + seen := map[string]bool{} + for _, r := range v.Replaces { + if strings.TrimSpace(r) == "" || strings.ContainsAny(r, "\n\r") || len(r) > MaxReplaced || seen[r] { + t.Errorf("%s.%s replaces %q, which is not one short line said once", s.Name, v.Name, r) + } + seen[r] = true + } + } + } +} + +// A verb's definition in a manifest may say what it replaces, like the mesh's own. +func TestAVerbDefinitionCarriesWhatItReplaces(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"till","version":"1","tools":["refund"],` + + `"seats":[{"name":"shop-till","serves":[{"name":"refund","replaces":["psql -c refund"]}]}],` + + `"claims":[{"name":"shop-till","scope":"mesh"}]}`)) + if err != nil { + t.Fatal(err) + } + if got := m.DefinesSeats[0].Serves[0].Replaces; len(got) != 1 || got[0] != "psql -c refund" { + t.Fatalf("replaces not read: %v", got) + } +} + +// A module says what its own tools replace, and only for a tool it declares. +func TestAModuleSaysWhatItsOwnToolsReplace(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"docker","version":"1","tools":["docker_logs","docker_list"],` + + `"replaces":{"docker_logs":["docker logs"],"docker_list":["docker ps"]}}`)) + if err != nil { + t.Fatal(err) + } + if m.Replaces["docker_logs"][0] != "docker logs" || m.Replaces["docker_list"][0] != "docker ps" { + t.Fatalf("replaces not read: %v", m.Replaces) + } + for _, c := range []struct{ manifest, says string }{ + {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_lgos":["docker logs"]}}`, + "declares no such tool"}, + {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":[]}}`, "replaces nothing"}, + {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":[" "]}}`, "empty command"}, + {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":["docker logs\nrm -rf /"]}}`, + "more than one line"}, + {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":["docker logs","docker logs"]}}`, + "twice"}, + {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":["` + strings.Repeat("x", MaxReplaced+1) + `"]}}`, + "longer than"}, + } { + if _, err := ParseManifest([]byte(c.manifest)); err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: want a refusal saying %q, got %v", c.manifest, c.says, err) + } + } +} diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index ff30ca7b..bc65db20 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -102,10 +102,10 @@ var defaultSeats = append([]Seat{ {Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079", Serves: []Verb{ {Name: "databases", Description: "Every database the store holds, with its on-disk size.", - Input: schema(map[string]string{}, nil)}, + Input: schema(map[string]string{}, nil), Replaces: []string{"psql -l"}}, {Name: "query", Description: "One read-only statement against one database the store holds.", Input: schema(map[string]string{"database": "the database to query", "sql": "the read-only statement"}, - []string{"database", "sql"})}, + []string{"database", "sql"}), Replaces: []string{"psql"}}, }}, // **Delivers the mesh's own bus, not `amqp`.** Those were the same word until // ADR 0127 separated them: `amqp` is a backing service a module may require, and this seat is @@ -174,15 +174,16 @@ var defaultSeats = append([]Seat{ Serves: []Verb{ {Name: "entries", Description: "Every line of this machine's /etc/hosts, each marked whose it is: " + "the operator's, or the block of the module or tool that writes it.", - Input: schema(map[string]string{}, nil)}, + Input: schema(map[string]string{}, nil), Replaces: []string{"cat /etc/hosts", "getent hosts"}}, {Name: "add", Description: "Add one address and its names to the operator's lines of this machine's " + "/etc/hosts — a name for this machine's own programs, not the mesh's.", Input: schema(map[string]string{"address": "the IPv4 or IPv6 address", - "names": "the names for it, separated by spaces"}, []string{"address", "names"})}, + "names": "the names for it, separated by spaces"}, []string{"address", "names"}), + Replaces: []string{"edit /etc/hosts", "HOSTALIASES"}}, {Name: "remove", Description: "Remove one name, or every line of one address, from the operator's " + "lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.", Input: schema(map[string]string{"name": "a host name, or an address to remove every line of"}, - []string{"name"})}, + []string{"name"}), Replaces: []string{"sed -i /etc/hosts"}}, }}, // The intrusion prevention's verbs (novox/hq ADR 0179): what a person asks a machine's ban list // whatever keeps it — who is banned and why, ban one address, let one go. Every holder serves all @@ -191,15 +192,17 @@ var defaultSeats = append([]Seat{ Serves: []Verb{ {Name: "status", Description: "Every jail on this machine with how many it is watching and " + "holding now, and the totals since the jail started; one jail's detail when named.", - Input: schema(map[string]string{"jail": "one jail (optional)"}, nil)}, + Input: schema(map[string]string{"jail": "one jail (optional)"}, nil), Replaces: []string{"fail2ban-client status"}}, {Name: "banned", Description: "Every address banned on this machine right now, with the jail " + "that holds it and when the ban ends.", - Input: schema(map[string]string{"jail": "one jail (optional)"}, nil)}, + Input: schema(map[string]string{"jail": "one jail (optional)"}, nil), Replaces: []string{"fail2ban-client banned"}}, {Name: "ban", Description: "Ban one address in one jail now, for the jail's ban time — an " + "operator's act on the live ban list, which the mesh never writes itself.", - Input: schema(map[string]string{"ip": "the address", "jail": "the jail to hold it"}, []string{"ip", "jail"})}, + Input: schema(map[string]string{"ip": "the address", "jail": "the jail to hold it"}, []string{"ip", "jail"}), + Replaces: []string{"fail2ban-client set banip"}}, {Name: "unban", Description: "Let one address go, from one jail or from every jail when none is named.", - Input: schema(map[string]string{"ip": "the address", "jail": "one jail (optional)"}, []string{"ip"})}, + Input: schema(map[string]string{"ip": "the address", "jail": "one jail (optional)"}, []string{"ip"}), + Replaces: []string{"fail2ban-client unban", "fail2ban-client set unbanip"}}, }}, // The packet filter's verbs (novox/hq ADR 0170): what a person asks a machine's filter whatever // filter answers — the rules as enforced, reload the mesh's own, remove one thing the mesh did @@ -210,15 +213,17 @@ var defaultSeats = append([]Seat{ "ruleset and, where the tool exists, the legacy filter's listings. Narrowed to one table or " + "chain when asked.", Input: schema(map[string]string{"table": "one nftables table, as `family name` (optional)", - "chain": "one chain of that table (optional)"}, nil)}, + "chain": "one chain of that table (optional)"}, nil), + Replaces: []string{"nft list ruleset", "iptables -L", "iptables-save"}}, {Name: "reload", Description: "Load the mesh's own filter again from the file the mesh writes, " + "and answer with the mesh's table as loaded.", - Input: schema(map[string]string{}, nil)}, + Input: schema(map[string]string{}, nil), Replaces: []string{"nft -f"}}, {Name: "remove", Description: "Remove one rule set the mesh did not write, named exactly as the " + "host reports it (novox/hq ADR 0168) — `chain X (iptables-legacy)` or `table ip6 filter, chain " + "DOCKER-USER`. Refuses the mesh's tables, the runtime's own chains, a built-in chain and an " + "active found firewall's chains. An operator's act, by name, never a flush.", - Input: schema(map[string]string{"where": "the rule set, as `node show` lists it"}, []string{"where"})}, + Input: schema(map[string]string{"where": "the rule set, as `node show` lists it"}, []string{"where"}), + Replaces: []string{"nft delete", "iptables -X"}}, }}, // The machine's service manager (novox/hq ADR 0177). The host applies every declared unit, // system or user scope; the holder answers questions and operator acts about them, each verb @@ -573,21 +578,22 @@ func serviceManagerVerbs() []Verb { unit := map[string]string{"unit": "the unit's name, as the service manager knows it"} return []Verb{ {Name: "units", Description: "The units the service manager knows in a scope, each with its load, active and sub state; narrowed to a pattern when asked.", - Input: scoped(map[string]string{"pattern": "a glob the unit's name must match (optional)"}, nil)}, + Input: scoped(map[string]string{"pattern": "a glob the unit's name must match (optional)"}, nil), Replaces: []string{"systemctl list-units", "systemctl --failed"}}, {Name: "status", Description: "One unit as the service manager sees it now: its states, whether it starts at boot, its main process, and whether the mesh declares it.", - Input: scoped(unit, []string{"unit"})}, + Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl status", "systemctl is-active"}}, {Name: "start", Description: "Start one unit. For a unit the mesh declares, the answer says the host will restore what its declaration says at the next apply.", - Input: scoped(unit, []string{"unit"})}, + Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl start"}}, {Name: "stop", Description: "Stop one unit; for a mesh-declared unit the answer says the host will restore its declared state.", - Input: scoped(unit, []string{"unit"})}, + Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl stop"}}, {Name: "restart", Description: "Restart one unit.", - Input: scoped(unit, []string{"unit"})}, + Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl restart"}}, {Name: "enable", Description: "Make one unit start at boot (or at the account's login, in user scope).", - Input: scoped(unit, []string{"unit"})}, + Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl enable"}}, {Name: "disable", Description: "Stop one unit starting at boot (or at login, in user scope).", - Input: scoped(unit, []string{"unit"})}, + Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl disable"}}, {Name: "journal", Description: "The last lines of one unit's journal.", - Input: scoped(map[string]string{"unit": unit["unit"], "lines": "how many lines from the end (default 100)"}, []string{"unit"})}, + Input: scoped(map[string]string{"unit": unit["unit"], "lines": "how many lines from the end (default 100)"}, []string{"unit"}), + Replaces: []string{"journalctl"}}, } } @@ -600,10 +606,10 @@ func backupVerbs() []Verb { return []Verb{ {Name: "backed-up", Description: "What this machine backs up: each module, what it declared, " + "its last good night, how many restore points are kept and the repository's size.", - Input: schema(map[string]string{"module": "one module (optional)"}, nil)}, + Input: schema(map[string]string{"module": "one module (optional)"}, nil), Replaces: []string{"restic snapshots"}}, {Name: "now", Description: "Take a backup now, of one module or of every module on this " + "machine — before a migration, a retirement or anything else that could go wrong.", - Input: schema(map[string]string{"module": "one module (optional)"}, nil)}, + Input: schema(map[string]string{"module": "one module (optional)"}, nil), Replaces: []string{"restic backup"}}, {Name: "restore", Description: "Restore one module's data from a restore point BESIDE the live " + "data, never over it: each directory as .restored-. Swapping it in is a " + "person's act. Lists the restore points when none is named.", @@ -611,7 +617,7 @@ func backupVerbs() []Verb { "module": "the module", "snapshot": "the restore point (from `backed-up`; the newest when omitted)", "path": "one of the module's directories (all of them when omitted)", - }, []string{"module"})}, + }, []string{"module"}), Replaces: []string{"restic restore"}}, } } diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index 33e9df66..c8aed9fd 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -22,6 +22,13 @@ type Verb struct { Description string `json:"description,omitempty"` Input map[string]any `json:"input,omitempty"` Output map[string]any `json:"output,omitempty"` + // Replaces are the shell commands this verb is the mesh's way to do, each as it is typed — + // `journalctl`, `systemctl restart` — so the agent finds the verb by the command it would have run + // and is told to call it instead (novox/hq ADR 0244): the console's search matches them, the agent's + // instructions list them, and the guard on its shell names the verb when it refuses a work-around. + // A command line is matched by its words in order, the first being the command's name; `edit + // /etc/hosts` and `HOSTALIASES` name the guard's two local work-arounds for a mesh name. + Replaces []string `json:"replaces,omitempty"` } func (v *Verb) UnmarshalJSON(raw []byte) error { @@ -86,7 +93,8 @@ var ControllerVerbs = []Verb{ {Name: "nodes", Description: "Every machine the mesh knows, with whether it is converged or adopted.", Input: schema(nil, nil)}, {Name: "node", Description: "What one machine reported it can do, what it is assigned, and why.", - Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})}, + Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"}), + Replaces: []string{"hostnamectl", "uptime"}}, {Name: "modules", Description: "Every module the mesh holds: version, the commit it was built from, " + "and which machines run it.", Input: schema(nil, nil)},