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.
361 lines
12 KiB
Go
361 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
"github.com/novox/mesh-controller/internal/link"
|
|
)
|
|
|
|
// The mesh's own verbs, served as the mesh-controller seat's tools (novox/hq ADR 0154, design 33).
|
|
//
|
|
// **Each tool runs the command it names, in this same binary, and answers what it printed.** That is
|
|
// ADR 0035 taken literally: the logic lives once, in the command, and a surface is an adapter with no
|
|
// decisions in it. Running a fresh process rather than calling the function keeps two things true
|
|
// that calling it would not — every command opens and closes its own stores the way it does from a
|
|
// shell, and nothing a command prints to the process's standard output can leak into another call's
|
|
// answer. It also means a refusal is the same refusal in the same words, because it is the same
|
|
// output.
|
|
|
|
// verbAnswer is what a verb answers: what the command printed, whether it succeeded, and — where the
|
|
// command speaks JSON — the same as data.
|
|
type verbAnswer struct {
|
|
Output string `json:"output"`
|
|
OK bool `json:"ok"`
|
|
Answer any `json:"answer,omitempty"`
|
|
}
|
|
|
|
// argvFor is the command line a verb and its arguments become. Only the verbs the seat declares, and
|
|
// only the arguments each declares: a caller cannot reach a flag the schema did not name.
|
|
func argvFor(verb string, args map[string]any) ([]string, error) {
|
|
str := func(key string) string {
|
|
v, _ := args[key].(string)
|
|
return strings.TrimSpace(v)
|
|
}
|
|
need := func(keys ...string) error {
|
|
for _, k := range keys {
|
|
if str(k) == "" {
|
|
return fmt.Errorf("%s needs %q", verb, k)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
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":
|
|
return []string{"status", "--json"}, nil
|
|
case "nodes":
|
|
return []string{"node", "list"}, nil
|
|
case "node":
|
|
if err := need("node"); err != nil {
|
|
return nil, err
|
|
}
|
|
return []string{"node", "show", str("node")}, nil
|
|
case "modules":
|
|
return []string{"module", "list"}, nil
|
|
case "seats":
|
|
return []string{"seats", "--json"}, nil
|
|
case "builds":
|
|
if id := str("log"); id != "" {
|
|
return []string{"builds", "--log", id}, nil
|
|
}
|
|
if m := str("module"); m != "" {
|
|
return []string{"builds", m}, nil
|
|
}
|
|
return []string{"builds"}, nil
|
|
case "plans":
|
|
if r := str("repository"); r != "" {
|
|
argv := []string{"plans", "--what-if", r}
|
|
if p := str("paths"); p != "" {
|
|
argv = append(argv, "--paths", p)
|
|
}
|
|
if m := str("modules"); m != "" {
|
|
argv = append(argv, "--modules", m)
|
|
}
|
|
return argv, nil
|
|
}
|
|
if id := str("stop"); id != "" {
|
|
return []string{"plans", "stop", id}, nil
|
|
}
|
|
if id := str("id"); id != "" {
|
|
return []string{"plans", id}, nil
|
|
}
|
|
return []string{"plans"}, nil
|
|
case "plan":
|
|
if err := need("node"); err != nil {
|
|
return nil, err
|
|
}
|
|
return []string{"plan", str("node"), "--json"}, nil
|
|
case "assign", "unassign":
|
|
if err := need("node", "module"); err != nil {
|
|
return nil, err
|
|
}
|
|
return []string{verb, str("node"), str("module")}, nil
|
|
case "pin":
|
|
if err := need("node", "provision", "from", "module"); err != nil {
|
|
return nil, err
|
|
}
|
|
return []string{"pin", str("node"), str("provision"), str("from"), str("module")}, nil
|
|
case "unpin":
|
|
if err := need("node", "provision"); err != nil {
|
|
return nil, err
|
|
}
|
|
return []string{"unpin", str("node"), str("provision")}, nil
|
|
case "push":
|
|
// Sent and not waited for: the asker reads `status` for what the machine did, which is
|
|
// what a person at a shell does too. A tool call that blocked for a push's whole apply would
|
|
// time out on every machine that takes a minute, and say nothing about the ones that did not.
|
|
if n := str("node"); n != "" {
|
|
return []string{"push", n, "--wait", "0"}, nil
|
|
}
|
|
return []string{"push", "--behind", "--wait", "0"}, nil
|
|
case "rotate":
|
|
if p := str("provision"); p != "" {
|
|
argv := []string{"rotate", p}
|
|
if c := str("consumer"); c != "" {
|
|
argv = append(argv, "--consumer", c)
|
|
}
|
|
return argv, nil
|
|
}
|
|
if str("node") != "" && str("module") != "" && str("secret") != "" {
|
|
return []string{"secret", "rotate", str("node"), str("module"), str("secret")}, nil
|
|
}
|
|
// Half of either shape: the command says its usage, which names both shapes, and that is
|
|
// the answer the caller needs.
|
|
return []string{"rotate"}, nil
|
|
case "settings":
|
|
// `settings set|clear` at a shell (novox/hq issue 198). The values travel as an argument
|
|
// because a tool has no file to hand the command; the command reads either.
|
|
if err := need("module"); err != nil {
|
|
return nil, err
|
|
}
|
|
argv := []string{"settings", "set", str("module")}
|
|
switch {
|
|
case str("clear") == "true":
|
|
argv = []string{"settings", "clear", str("module")}
|
|
case str("values") != "":
|
|
argv = append(argv, str("values"))
|
|
}
|
|
// Neither values nor clear: the command says its usage, which names both, and that is the
|
|
// answer the caller needs — the same as `rotate` given half of either shape.
|
|
if n := str("node"); n != "" {
|
|
argv = append(argv, "--node", n)
|
|
}
|
|
return argv, nil
|
|
case "issue":
|
|
// The same act as `module issue` at a shell (novox/hq design 25 §4): the account is minted
|
|
// into the mesh's records and delivered at the machine's next push, which is the caller's to
|
|
// ask for — so the mesh is never pushed as a side effect of a credential.
|
|
if err := need("node", "module"); err != nil {
|
|
return nil, err
|
|
}
|
|
return []string{"module", "issue", str("module"), "--node", str("node")}, nil
|
|
case "build":
|
|
if err := need("repository"); err != nil {
|
|
return nil, err
|
|
}
|
|
// Not waited for: a tool call cannot hold a connection for the minutes a build takes; the
|
|
// daemon takes the outcome in when it comes and the id follows the build (issue 176). A
|
|
// repository given without a scheme is a path on the forge holding the git seat.
|
|
argv := []string{"build", str("repository"), "--wait", "0"}
|
|
if !strings.Contains(str("repository"), "://") && !strings.HasPrefix(str("repository"), "git@") {
|
|
argv = append(argv, "--self")
|
|
}
|
|
if p := str("path"); p != "" {
|
|
argv = append(argv, "--path", p)
|
|
}
|
|
if r := str("ref"); r != "" {
|
|
argv = append(argv, "--ref", r)
|
|
}
|
|
return argv, nil
|
|
}
|
|
return nil, fmt.Errorf("%q is not a verb the %s seat serves", verb, catalogue.ControllerSeatName)
|
|
}
|
|
|
|
// jsonVerbs are the verbs whose command speaks JSON, so the answer carries it as data as well.
|
|
var jsonVerbs = map[string]bool{"status": true, "seats": true, "plan": true}
|
|
|
|
// runVerb runs this binary with the given command line and gathers what it said.
|
|
func runVerb(ctx context.Context, argv []string) (verbAnswer, error) {
|
|
self, err := os.Executable()
|
|
if err != nil {
|
|
return verbAnswer{}, err
|
|
}
|
|
cmd := exec.CommandContext(ctx, self, argv...)
|
|
// The same environment: the stores' credentials, the bus, the broker — everything a command run
|
|
// from a shell in this container would have, because it is that.
|
|
cmd.Env = os.Environ()
|
|
// Two buffers, one answer. What the command *says* is both streams, in the order a person at
|
|
// a shell would read them; what it *answers as data* is standard output alone — `status --json`
|
|
// prints its warnings beside the document, and a JSON parsed from the two together parsed
|
|
// nothing (2026-09-30, the first status asked through the console had no `answer`).
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
runErr := cmd.Run()
|
|
answer := verbAnswer{Output: stdout.String() + stderr.String(), OK: runErr == nil}
|
|
if jsonVerbs[argv[0]] && runErr == nil {
|
|
var parsed any
|
|
if json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &parsed) == nil {
|
|
answer.Answer = parsed
|
|
}
|
|
}
|
|
var exit *exec.ExitError
|
|
if runErr != nil && !errors.As(runErr, &exit) {
|
|
// Not the command refusing — the command not running at all, which is this process's fault.
|
|
return answer, fmt.Errorf("could not run %s: %w", strings.Join(argv, " "), runErr)
|
|
}
|
|
return answer, nil
|
|
}
|
|
|
|
// seatToolHandlers are the handlers for every verb the mesh-controller seat declares, from the
|
|
// store's row, so a verb the row does not carry is not served and a verb it carries that this binary
|
|
// cannot run is said at start rather than at the first call.
|
|
func seatToolHandlers() (map[string]link.ToolHandler, error) {
|
|
seat, known := catalogue.SeatNamed(catalogue.ControllerSeatName)
|
|
if !known {
|
|
return nil, fmt.Errorf("this mesh defines no %s seat", catalogue.ControllerSeatName)
|
|
}
|
|
handlers := map[string]link.ToolHandler{}
|
|
for _, v := range seat.Serves {
|
|
verb := v.Name
|
|
if verb == "tools" {
|
|
handlers[verb] = func(ctx context.Context, _ json.RawMessage) (any, error) {
|
|
return seatTools(), nil
|
|
}
|
|
continue
|
|
}
|
|
if _, err := argvFor(verb, sampleArguments(v)); err != nil {
|
|
return nil, fmt.Errorf("the %s seat's row declares %q, which this control plane cannot run: %w",
|
|
catalogue.ControllerSeatName, verb, err)
|
|
}
|
|
handlers[verb] = func(ctx context.Context, raw json.RawMessage) (any, error) {
|
|
args := map[string]any{}
|
|
if len(raw) > 0 {
|
|
if err := json.Unmarshal(raw, &args); err != nil {
|
|
return nil, fmt.Errorf("the arguments are not a JSON object: %w", err)
|
|
}
|
|
}
|
|
argv, err := argvFor(verb, args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return runVerb(ctx, argv)
|
|
}
|
|
}
|
|
return handlers, nil
|
|
}
|
|
|
|
// seatTools is what `tools` answers: every seat with a protocol, and the tools each serves, from the
|
|
// mesh's own records — no holder in the path, so it is true while a holder restarts (design 33 §5).
|
|
func seatTools() map[string]any {
|
|
var seats []map[string]any
|
|
for _, s := range catalogue.SeatsWithAProtocol() {
|
|
if len(s.Serves) == 0 {
|
|
continue
|
|
}
|
|
var tools []map[string]any
|
|
for _, v := range s.Serves {
|
|
tools = append(tools, map[string]any{
|
|
"name": v.Name, "description": v.Description, "input": v.Input, "output": v.Output,
|
|
})
|
|
}
|
|
seats = append(seats, map[string]any{"seat": s.Name, "scope": s.Scope, "tools": tools})
|
|
}
|
|
return map[string]any{"seats": seats}
|
|
}
|
|
|
|
// sampleArguments is one of every argument a verb's schema requires, so the check at start proves the
|
|
// verb runnable rather than that it happens to want the arguments the check guessed.
|
|
func sampleArguments(v catalogue.Verb) map[string]any {
|
|
sample := map[string]any{"node": "x", "module": "x", "repository": "x"}
|
|
switch required := v.Input["required"].(type) {
|
|
case []string:
|
|
for _, k := range required {
|
|
sample[k] = "x"
|
|
}
|
|
case []any:
|
|
for _, k := range required {
|
|
if name, ok := k.(string); ok {
|
|
sample[name] = "x"
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|