The controller seat gains a generic verb: command runs one line of the binary and answers what it printed
Beside the named verbs, `command` takes a command line as the controller's own shell would — `node account g14 jochen`, `node show ace`, `module list` — splits it as a shell does (quotes group, backslash escapes, nothing expanded) and runs it in this binary like every other verb. The named verbs keep their schemas; this is the whole binary, added because the operator decided any node may call any tool (hq ADR 0175) and a verb per command was the only thing keeping the rest behind a shell on the control node. ADR 0154 carries the dated note. Tests: a plain line, quoted words, an empty line and an unclosed quote refused.
This commit is contained in:
@@ -48,6 +48,21 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
switch verb {
|
switch verb {
|
||||||
|
case "command":
|
||||||
|
// The generic verb: the command line as given, split as a shell would split it, with
|
||||||
|
// nothing added — the named verbs add flags a caller cannot reach; this one is the whole
|
||||||
|
// binary and says so in its description (novox/hq ADR 0154, 0175).
|
||||||
|
if err := need("command"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
argv, err := splitCommandLine(str("command"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(argv) == 0 {
|
||||||
|
return nil, errors.New("command names no command")
|
||||||
|
}
|
||||||
|
return argv, nil
|
||||||
case "status":
|
case "status":
|
||||||
return []string{"status", "--json"}, nil
|
return []string{"status", "--json"}, nil
|
||||||
case "nodes":
|
case "nodes":
|
||||||
@@ -289,3 +304,57 @@ func sampleArguments(v catalogue.Verb) map[string]any {
|
|||||||
}
|
}
|
||||||
return sample
|
return sample
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// splitCommandLine splits a command line into words the way a POSIX shell does for the simple
|
||||||
|
// cases a controller command needs: spaces separate, single or double quotes group, a backslash
|
||||||
|
// escapes the next character inside double quotes or outside any. No expansion of anything.
|
||||||
|
func splitCommandLine(line string) ([]string, error) {
|
||||||
|
var words []string
|
||||||
|
var cur strings.Builder
|
||||||
|
inWord := false
|
||||||
|
quote := rune(0)
|
||||||
|
runes := []rune(line)
|
||||||
|
for i := 0; i < len(runes); i++ {
|
||||||
|
r := runes[i]
|
||||||
|
switch {
|
||||||
|
case quote == '\'':
|
||||||
|
if r == '\'' {
|
||||||
|
quote = 0
|
||||||
|
} else {
|
||||||
|
cur.WriteRune(r)
|
||||||
|
}
|
||||||
|
case quote == '"':
|
||||||
|
if r == '"' {
|
||||||
|
quote = 0
|
||||||
|
} else if r == '\\' && i+1 < len(runes) {
|
||||||
|
i++
|
||||||
|
cur.WriteRune(runes[i])
|
||||||
|
} else {
|
||||||
|
cur.WriteRune(r)
|
||||||
|
}
|
||||||
|
case r == '\'' || r == '"':
|
||||||
|
quote = r
|
||||||
|
inWord = true
|
||||||
|
case r == '\\' && i+1 < len(runes):
|
||||||
|
i++
|
||||||
|
cur.WriteRune(runes[i])
|
||||||
|
inWord = true
|
||||||
|
case r == ' ' || r == '\t' || r == '\n':
|
||||||
|
if inWord {
|
||||||
|
words = append(words, cur.String())
|
||||||
|
cur.Reset()
|
||||||
|
inWord = false
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
cur.WriteRune(r)
|
||||||
|
inWord = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if quote != 0 {
|
||||||
|
return nil, fmt.Errorf("command has an unclosed %c quote", quote)
|
||||||
|
}
|
||||||
|
if inWord {
|
||||||
|
words = append(words, cur.String())
|
||||||
|
}
|
||||||
|
return words, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -171,3 +171,27 @@ func TestAJSONVerbsAnswerIsItsStandardOutput(t *testing.T) {
|
|||||||
t.Fatalf("stderr and stdout are both what the command said: %s", answer.Output)
|
t.Fatalf("stderr and stdout are both what the command said: %s", answer.Output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `command` is the generic verb: the command line as given, split as a shell would, nothing added —
|
||||||
|
// so an operator's `node account g14 jochen` is one call through the console rather than a shell on
|
||||||
|
// the control node (novox/hq ADR 0154, ADR 0175).
|
||||||
|
func TestCommandRunsTheLineAsGiven(t *testing.T) {
|
||||||
|
argv, err := argvFor("command", map[string]any{"command": "node account g14 jochen"})
|
||||||
|
if err != nil || strings.Join(argv, " ") != "node account g14 jochen" {
|
||||||
|
t.Fatalf("a plain line: %v %v", argv, err)
|
||||||
|
}
|
||||||
|
argv, err = argvFor("command", map[string]any{"command": `settings set dnsmasq '{"a": "b c"}' --node ace`})
|
||||||
|
if err != nil || len(argv) != 6 || argv[3] != `{"a": "b c"}` {
|
||||||
|
t.Fatalf("a quoted word stays one word: %q %v", argv, err)
|
||||||
|
}
|
||||||
|
argv, err = argvFor("command", map[string]any{"command": `node add "the box" --adopted`})
|
||||||
|
if err != nil || len(argv) != 4 || argv[2] != "the box" {
|
||||||
|
t.Fatalf("double quotes group: %q %v", argv, err)
|
||||||
|
}
|
||||||
|
if _, err := argvFor("command", map[string]any{"command": " "}); err == nil {
|
||||||
|
t.Fatal("an empty line was accepted")
|
||||||
|
}
|
||||||
|
if _, err := argvFor("command", map[string]any{"command": `node "unclosed`}); err == nil {
|
||||||
|
t.Fatal("an unclosed quote was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -145,6 +145,13 @@ var ControllerVerbs = []Verb{
|
|||||||
"node": "one machine; the whole mesh when absent",
|
"node": "one machine; the whole mesh when absent",
|
||||||
"clear": "\"true\" to remove the layer instead of setting it",
|
"clear": "\"true\" to remove the layer instead of setting it",
|
||||||
}, []string{"module"})},
|
}, []string{"module"})},
|
||||||
|
{Name: "command", Description: "Run one command line of the controller's own, as you would type it at its " +
|
||||||
|
"shell — `node account g14 jochen`, `node show ace`, `module list` — and answer what it printed. The " +
|
||||||
|
"generic verb beside the named ones (novox/hq ADR 0154): everything the binary can do, without a verb " +
|
||||||
|
"per command. Any node may call any tool (ADR 0175), so nothing is held back here.",
|
||||||
|
Input: schema(map[string]string{
|
||||||
|
"command": "the command line, as the controller's binary takes it; quotes group a word with spaces",
|
||||||
|
}, []string{"command"})},
|
||||||
{Name: "build", Description: "Have the build machine build a repository. Answers at once with the build's id: " +
|
{Name: "build", Description: "Have the build machine build a repository. Answers at once with the build's id: " +
|
||||||
"`builds` with that id follows it line by line, and the module is registered when the outcome comes.",
|
"`builds` with that id follows it line by line, and the module is registered when the outcome comes.",
|
||||||
Input: schema(map[string]string{
|
Input: schema(map[string]string{
|
||||||
|
|||||||
Reference in New Issue
Block a user