From 0e46b8302d0d5f39f15edccab610bd1c13bb282f Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 12:24:52 +0200 Subject: [PATCH] Let root-free take its machines as a list, as the router names them The router's contract names the machines as a JSON array. A verb's argument declared a list now takes an array of names (or one text separated by commas), and refuses anything else in it. --- cmd/mesh-controller/probe_agent_root_test.go | 15 ++++++++++++-- cmd/mesh-controller/seatverbs.go | 21 ++++++++++++++++++++ internal/catalogue/verbs.go | 21 +++++++++++++++++--- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/cmd/mesh-controller/probe_agent_root_test.go b/cmd/mesh-controller/probe_agent_root_test.go index dbf6d275..3a8da86e 100644 --- a/cmd/mesh-controller/probe_agent_root_test.go +++ b/cmd/mesh-controller/probe_agent_root_test.go @@ -181,7 +181,18 @@ func TestRootFreeIsAnsweredOnlyByTheServingController(t *testing.T) { if _, err := argvFor("root-free", map[string]any{"machines": "anchor"}); err == nil { t.Error("root-free ran as a command") } - if _, err := readArguments("root-free", map[string]any{"machines": []any{"anchor"}}); err == nil { - t.Error("root-free took its machines as a list; they are named in one text, separated by commas") + // The router names its machines as a list (its contract with this verb); one text separated by commas is + // the same; anything else in the list is refused. + for _, given := range []any{[]any{"anchor", "relay"}, "anchor, relay"} { + a, err := readArguments("root-free", map[string]any{"machines": given}) + if err != nil || a.given["machines"] != "anchor,relay" && a.given["machines"] != "anchor, relay" { + t.Errorf("root-free given %v read %v (%v)", given, a, err) + } + } + if _, err := readArguments("root-free", map[string]any{"machines": []any{"anchor", 7}}); err == nil { + t.Error("root-free took a number for a machine") + } + if _, err := readArguments("status", map[string]any{"machines": []any{"anchor"}}); err == nil { + t.Error("a verb that takes no list took one") } } diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index f53cb1cd..c22a6cf0 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -114,6 +114,14 @@ func declaredArguments(v catalogue.Verb) (names []string, switches map[string]bo return names, switches } +// isList says a verb's argument is declared a list of text (catalogue's listed): given as a JSON array, it is +// read as its items joined by commas, as the same argument given as one text would be. +func isList(v catalogue.Verb, name string) bool { + props, _ := v.Input["properties"].(map[string]any) + p, _ := props[name].(map[string]any) + return p != nil && p["type"] == "array" +} + // readArguments refuses what the verb does not take, before anything is composed. func readArguments(verb string, args map[string]any) (*verbArguments, error) { v, known := controllerVerb(verb) @@ -150,6 +158,19 @@ func readArguments(verb string, args map[string]any) (*verbArguments, error) { return nil, fmt.Errorf("%s: %q is text, not true or false", verb, k) } value = fmt.Sprint(x) + case []any: + if !isList(v, k) { + return nil, fmt.Errorf("%s: %q is text, and was given a list", verb, k) + } + items := make([]string, 0, len(x)) + for _, item := range x { + text, ok := item.(string) + if !ok || strings.TrimSpace(text) == "" || strings.Contains(text, ",") { + return nil, fmt.Errorf("%s: %q is a list of names, and holds %v", verb, k, item) + } + items = append(items, strings.TrimSpace(text)) + } + value = strings.Join(items, ",") default: return nil, fmt.Errorf("%s: %q is text, and was given %T", verb, k, x) } diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index eccd04dd..f63b91df 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -435,9 +435,9 @@ var ControllerVerbs = []Verb{ "last 15 minutes, and the login shell's execute is not served there. Anything else, a read that failed " + "included, is not free and says why. The router asks it before an answer from a channel proving its sender " + "may approve. Only reads.", - Input: schema(map[string]string{ - "machines": "the machines to judge, separated by commas", - }, []string{"machines"})}, + Input: listed(schema(map[string]string{ + "machines": "the machines to judge, by name: a list, or one text separated by commas", + }, []string{"machines"}), "machines")}, {Name: "dead-letters", Description: "Every message a consumer on the bus gave up on after handing it over " + "as often as it may, kept in DEAD_LETTERS: whose consumer, the subject, how often it was handed over and " + "when it was given up, newest first. With id: that one whole, with what it said. With deliver: hand it " + @@ -554,6 +554,21 @@ func schema(properties map[string]string, required []string, switches ...string) return out } +// listed makes the named properties of a schema lists of text: a caller gives them as a JSON array (or, as +// any argument, one text separated by commas). +func listed(in map[string]any, names ...string) map[string]any { + props, _ := in["properties"].(map[string]any) + for _, n := range names { + p, _ := props[n].(map[string]any) + if p == nil { + panic("a list that is not a property: " + n) + } + props[n] = map[string]any{"type": "array", "items": map[string]any{"type": "string"}, + "description": p["description"]} + } + return in +} + // unpromised is what a claim says it serves and the seat's protocol never promised. func unpromised(serves []string, promised []Verb) []string { has := map[string]bool{}