status --json prints its warnings beside the document; parsed from both streams together the first status asked through the console carried no answer as data. Output stays both streams, in order.
195 lines
6.9 KiB
Go
195 lines
6.9 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()
|
|
// 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, 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}
|
|
}
|