A seat's protocol lives in the store (migration 0047; seeded additively), a served verb carries its description and schema, holding a mesh seat requires serving its verbs, a node-scoped seat's tool carries the node, and the control plane serves status, nodes, node, modules, seats, builds, plan, assign, unassign, push, build and tools on its seat by running the same commands (novox/hq ADR 0132, ADR 0154, design 33). A grant of * reaches a role's tools; seat:<seat>.<verb> grants one.
191 lines
6.5 KiB
Go
191 lines
6.5 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 "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 m := str("module"); m != "" {
|
|
return []string{"builds", m}, nil
|
|
}
|
|
return []string{"builds"}, 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 "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 "build":
|
|
if err := need("repository"); err != nil {
|
|
return nil, err
|
|
}
|
|
argv := []string{"build", str("repository"), "--wait", "0"}
|
|
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()
|
|
var out bytes.Buffer
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = &out
|
|
runErr := cmd.Run()
|
|
answer := verbAnswer{Output: out.String(), OK: runErr == nil}
|
|
if jsonVerbs[argv[0]] && runErr == nil {
|
|
var parsed any
|
|
if json.Unmarshal(bytes.TrimSpace(out.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, map[string]any{"node": "x", "module": "x", "repository": "x"}); 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}
|
|
}
|