mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery covered: a later merge that contains it was delivered: novox/mesh-controller@14ab2ddd9b49 (merged as 63e85b25 into main, walk plan-17915459…
- The generic command verb only reads now (commandReads) and terminal-only commands are refused through any verb (terminalOnly). mesh-cli's ordinary line made neither check: `node account`, `token issue` and `secret export` from another node would have run. It now meets both, in the one function the command verb shares. - The serving controller marks itself and its children never the terminal (ADR 0266); a line mesh-cli runs as the terminal drops that mark and carries MESH_CLI_TERMINAL, so it reads as the terminal it is. - Two withholding tests searched the answer's text while JSON writes bytes as base64, so they held nothing. They search both now, each proved by disabling what it guards (Shown, the bus withholding, `calls` via Get). - The control-node refusal is tested through the assign and unassign acts.
1503 lines
56 KiB
Go
1503 lines
56 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"github.com/nats-io/nats.go/micro"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/novox/mesh-controller/internal/broker"
|
|
"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.
|
|
|
|
// verbKey carries the verb a call is for, to the process it runs.
|
|
type verbKey struct{}
|
|
|
|
// 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.
|
|
//
|
|
// **Nothing a caller sends is passed over** (novox/hq issue 244). An argument the verb does not
|
|
// declare is refused, naming it; a switch that is not "true" or "false" is refused; and an argument
|
|
// the verb declares but did not use for the command line it composed — given beside another that
|
|
// wins, or half of a shape — is refused too. On 2026-10-05 a push naming one machine reached the
|
|
// verb without the machine and ran as a push of every machine behind; a verb that answers "I did
|
|
// not take that" would have stopped it before anything was sent.
|
|
func argvFor(verb string, args map[string]any) ([]string, error) {
|
|
a, err := readArguments(verb, args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
argv, err := a.commandLine()
|
|
if err == nil {
|
|
err = terminalOnly(argv)
|
|
}
|
|
if len(a.misread) > 0 {
|
|
// The table and the command line disagree: the verb reads an argument no caller can see
|
|
// in its schema, so no caller could ever pass it.
|
|
return nil, fmt.Errorf("%s reads %s, which its schema does not declare — this build's verb "+
|
|
"table and its command lines disagree", verb, quoteAll(a.misread))
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if unused := a.unused(); len(unused) > 0 {
|
|
return nil, fmt.Errorf("%s did not use %s together with %s, and an argument a verb would pass over "+
|
|
"is refused: nothing was done", verb, quoteAll(unused), quoteAll(a.usedGiven()))
|
|
}
|
|
return argv, nil
|
|
}
|
|
|
|
// verbArguments are one call's arguments, checked against the verb's schema, and which of them the
|
|
// command line was composed from.
|
|
type verbArguments struct {
|
|
verb string
|
|
given map[string]string
|
|
used map[string]bool
|
|
declared map[string]bool
|
|
misread []string // arguments the command line read that the schema does not declare: a bug here
|
|
}
|
|
|
|
// controllerVerb is this binary's own definition of a verb: what it runs is what it declares, so the
|
|
// arguments are checked against the table compiled beside argvFor, not a row a newer or older build
|
|
// wrote.
|
|
func controllerVerb(name string) (catalogue.Verb, bool) {
|
|
for _, v := range catalogue.ControllerVerbs {
|
|
if v.Name == name {
|
|
return v, true
|
|
}
|
|
}
|
|
return catalogue.Verb{}, false
|
|
}
|
|
|
|
// declaredArguments are a schema's properties, and which of them are switches.
|
|
func declaredArguments(v catalogue.Verb) (names []string, switches map[string]bool) {
|
|
switches = map[string]bool{}
|
|
props, _ := v.Input["properties"].(map[string]any)
|
|
for name, p := range props {
|
|
names = append(names, name)
|
|
desc, _ := p.(map[string]any)
|
|
switch enum := desc["enum"].(type) {
|
|
case []string:
|
|
switches[name] = len(enum) == 2 && enum[0] == "true" && enum[1] == "false"
|
|
case []any:
|
|
switches[name] = len(enum) == 2 && enum[0] == "true" && enum[1] == "false"
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
return names, switches
|
|
}
|
|
|
|
// 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)
|
|
if !known {
|
|
return nil, fmt.Errorf("%q is not a verb the %s seat serves", verb, catalogue.ControllerSeatName)
|
|
}
|
|
names, switches := declaredArguments(v)
|
|
declared := map[string]bool{}
|
|
for _, n := range names {
|
|
declared[n] = true
|
|
}
|
|
takes := "none"
|
|
if len(names) > 0 {
|
|
takes = quoteAll(names)
|
|
}
|
|
a := &verbArguments{verb: verb, given: map[string]string{}, used: map[string]bool{}, declared: declared}
|
|
keys := make([]string, 0, len(args))
|
|
for k := range args {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, k := range keys {
|
|
if !declared[k] {
|
|
return nil, fmt.Errorf("%s takes no argument %q — it takes %s; nothing was done", verb, k, takes)
|
|
}
|
|
var value string
|
|
switch x := args[k].(type) {
|
|
case nil:
|
|
continue
|
|
case string:
|
|
value = strings.TrimSpace(x)
|
|
case bool:
|
|
if !switches[k] {
|
|
return nil, fmt.Errorf("%s: %q is text, not true or false", verb, k)
|
|
}
|
|
value = fmt.Sprint(x)
|
|
default:
|
|
return nil, fmt.Errorf("%s: %q is text, and was given %T", verb, k, x)
|
|
}
|
|
if switches[k] {
|
|
switch value {
|
|
case "true":
|
|
case "false", "":
|
|
continue // said and off: the same as not given, and nothing passed over
|
|
default:
|
|
return nil, fmt.Errorf("%s: %q is \"true\" or \"false\", not %q", verb, k, value)
|
|
}
|
|
}
|
|
if value != "" {
|
|
a.given[k] = value
|
|
}
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// str is one argument's value, marked as used.
|
|
func (a *verbArguments) str(key string) string {
|
|
if !a.declared[key] {
|
|
a.misread = append(a.misread, key)
|
|
}
|
|
a.used[key] = true
|
|
return a.given[key]
|
|
}
|
|
|
|
// on is a switch, marked as used.
|
|
func (a *verbArguments) on(key string) bool { return a.str(key) == "true" }
|
|
|
|
// need refuses a call missing a required argument, in the verb's own words.
|
|
func (a *verbArguments) need(keys ...string) error {
|
|
for _, k := range keys {
|
|
if a.str(k) == "" {
|
|
return fmt.Errorf("%s needs %q", a.verb, k)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// unused are the arguments given that the command line was not composed from.
|
|
func (a *verbArguments) unused() []string {
|
|
var out []string
|
|
for k := range a.given {
|
|
if !a.used[k] {
|
|
out = append(out, k)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func (a *verbArguments) usedGiven() []string {
|
|
var out []string
|
|
for k := range a.given {
|
|
if a.used[k] {
|
|
out = append(out, k)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
if len(out) == 0 {
|
|
return []string{"nothing"}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func quoteAll(xs []string) string {
|
|
q := make([]string, len(xs))
|
|
for i, x := range xs {
|
|
if x == "nothing" {
|
|
q[i] = x
|
|
continue
|
|
}
|
|
q[i] = fmt.Sprintf("%q", x)
|
|
}
|
|
return strings.Join(q, ", ")
|
|
}
|
|
|
|
// refusedAsTheGenericCommand is what the generic `command` verb refuses of a command line, and so what every
|
|
// line asked through a verb's route refuses: the `command` verb's, and an ordinary line from mesh-cli (novox/hq ADR
|
|
// 0272 §4). One function, so the two routes cannot drift apart.
|
|
func refusedAsTheGenericCommand(argv []string) error {
|
|
if len(argv) == 0 {
|
|
return errors.New("command names no command")
|
|
}
|
|
// A layer is written through the settings verb, never the generic one (novox/hq issue 339): the
|
|
// settings verb is where what a verb may not set is refused, and one route is one set of words.
|
|
// The command refuses places and accesses through any verb as well; this says so before it runs.
|
|
if argv[0] == "settings" && slices.ContainsFunc(argv[1:], func(w string) bool { return w == "set" || w == "clear" }) {
|
|
return &heldAtTheTerminal{msg: "settings are set and cleared through the settings verb, not the " +
|
|
"generic command; and places and accesses only at the controller's terminal (novox/hq issue 339). " +
|
|
"Nothing was done"}
|
|
}
|
|
// The generic verb only reads (novox/hq ADR 0266): what writes has a named verb that composes its own
|
|
// line, or is the operator's at the controller's terminal.
|
|
if err := commandReads(argv); err != nil {
|
|
return err
|
|
}
|
|
// The generic verb is no way round the hand-act log (novox/hq to-be 45 §7): a repair through
|
|
// it says why, as it would through its own verb.
|
|
if repair := repairingCommand(argv); repair != "" && !slices.ContainsFunc(argv, isWhyFlag) {
|
|
return fmt.Errorf("%s is a repair done by hand, and says why: add --why <text> to the command "+
|
|
"line (recorded in the hand-act log). Nothing was done", repair)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// commandLine composes the command. Every argument it reads is one it uses: a branch that reads an
|
|
// argument and then drops it would pass it over, which is what the check after it exists to refuse.
|
|
func (a *verbArguments) commandLine() ([]string, error) {
|
|
verb, str, on, need := a.verb, a.str, a.on, a.need
|
|
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 err := refusedAsTheGenericCommand(argv); err != nil {
|
|
return nil, err
|
|
}
|
|
return argv, nil
|
|
case "tools":
|
|
return nil, errors.New("tools is answered from the records, not by a command")
|
|
case "dead-letters":
|
|
return nil, errors.New("dead-letters is answered by the serving controller, on its own connection, not by a command")
|
|
case "status":
|
|
return []string{"status", "--json"}, nil
|
|
case "nodes":
|
|
return []string{"node", "list", "--json"}, 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", "--json"}, nil
|
|
case "seats":
|
|
return []string{"seats", "--json"}, nil
|
|
case "builds":
|
|
if id := str("log"); id != "" {
|
|
return []string{"builds", "--log", id}, nil
|
|
}
|
|
argv := []string{"builds"}
|
|
if n := str("limit"); n != "" {
|
|
argv = append(argv, "-n", n)
|
|
}
|
|
if m := str("module"); m != "" {
|
|
argv = append(argv, m)
|
|
}
|
|
return argv, 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)
|
|
}
|
|
if d := str("module-dirs"); d != "" {
|
|
argv = append(argv, "--module-dirs", d)
|
|
}
|
|
return argv, nil
|
|
}
|
|
for _, act := range []string{"stop", "close", "retry", "go"} {
|
|
if id := str(act); id != "" {
|
|
argv := []string{"plans", act, id}
|
|
if act == "retry" {
|
|
return argv, nil
|
|
}
|
|
// Ending a plan by hand says why (novox/hq to-be 45 §7); the command refuses it without.
|
|
if w := str("why"); w != "" {
|
|
argv = append(argv, "--why", w)
|
|
}
|
|
if c := str("cause"); c != "" {
|
|
argv = append(argv, "--cause", c)
|
|
}
|
|
return argv, nil
|
|
}
|
|
}
|
|
if id := str("id"); id != "" {
|
|
return []string{"plans", id}, nil
|
|
}
|
|
argv := []string{"plans"}
|
|
if n := str("limit"); n != "" {
|
|
argv = append(argv, "-n", n)
|
|
}
|
|
return argv, nil
|
|
// The delivery's owner's verbs (novox/hq ADR 0239).
|
|
case "delivery-plan":
|
|
if err := need("repository", "paths"); err != nil {
|
|
return nil, err
|
|
}
|
|
argv := []string{"delivery", "plan", "--repository", str("repository"), "--paths", str("paths")}
|
|
for _, flag := range []string{"head", "base", "module-dirs", "removed"} {
|
|
if v := str(flag); v != "" {
|
|
argv = append(argv, "--"+flag, v)
|
|
}
|
|
}
|
|
return argv, nil
|
|
case "delivery-order":
|
|
if err := need("members"); err != nil {
|
|
return nil, err
|
|
}
|
|
return []string{"delivery", "order", "--members", str("members")}, nil
|
|
case "delivery-check":
|
|
if err := need("members"); err != nil {
|
|
return nil, err
|
|
}
|
|
if g := str("group"); g != "" {
|
|
return []string{"delivery", "check", "--group", g, "--members", str("members")}, nil
|
|
}
|
|
return []string{"delivery", "check", "--members", str("members")}, nil
|
|
case "deliver":
|
|
if err := need("plan"); err != nil {
|
|
return nil, err
|
|
}
|
|
argv := []string{"delivery", "go", str("plan"), "--by", catalogue.DeliverySeat}
|
|
if w := str("why"); w != "" {
|
|
argv = append(argv, "--why", w)
|
|
}
|
|
return argv, nil
|
|
case "delivery-stop":
|
|
if err := need("plan", "why"); err != nil {
|
|
return nil, err
|
|
}
|
|
argv := []string{"delivery", "stop", str("plan"), "--why", str("why")}
|
|
if b := str("by"); b != "" {
|
|
argv = append(argv, "--by", catalogue.DeliverySeat+" for "+b)
|
|
}
|
|
return argv, nil
|
|
case "delivery-walks":
|
|
argv := []string{"delivery", "walks"}
|
|
if id := str("plan"); id != "" {
|
|
return append(argv, "--plan", id), nil
|
|
}
|
|
if n := str("limit"); n != "" {
|
|
argv = append(argv, "-n", n)
|
|
}
|
|
return argv, nil
|
|
// The build queue (novox/hq ADR 0219).
|
|
case "queue":
|
|
return []string{"queue"}, nil
|
|
case "cancel", "kill":
|
|
if err := need("id"); err != nil {
|
|
return nil, err
|
|
}
|
|
return []string{verb, str("id")}, nil
|
|
case "clear":
|
|
if on("dead") {
|
|
return []string{"clear", "--dead"}, nil
|
|
}
|
|
return []string{"clear"}, nil
|
|
case "rebuild":
|
|
if err := need("what"); err != nil {
|
|
return nil, err
|
|
}
|
|
return []string{"rebuild", str("what")}, nil
|
|
case "replay":
|
|
if err := need("id"); err != nil {
|
|
return nil, err
|
|
}
|
|
argv := []string{"replay", str("id")}
|
|
if on("register") {
|
|
argv = append(argv, "--register")
|
|
}
|
|
if on("older") {
|
|
argv = append(argv, "--older")
|
|
}
|
|
return argv, nil
|
|
case "pause", "resume":
|
|
if n := str("node"); n != "" {
|
|
return []string{verb, n}, nil
|
|
}
|
|
return []string{verb}, nil
|
|
case "plan":
|
|
if err := need("node"); err != nil {
|
|
return nil, err
|
|
}
|
|
if on("files") {
|
|
return []string{"plan", str("node"), "--files"}, nil
|
|
}
|
|
// What a push would change there (novox/hq ADR 0217); diff with files is passed over, and so refused.
|
|
if on("diff") {
|
|
return []string{"plan", str("node"), "--diff"}, nil
|
|
}
|
|
return []string{"plan", str("node"), "--json"}, nil
|
|
case "assign", "unassign":
|
|
if err := need("node", "module"); err != nil {
|
|
return nil, err
|
|
}
|
|
// Several modules comma-separated, judged as one act (novox/hq ADR 0207): the holders of
|
|
// the seats that apply resources depend on each other and go on together.
|
|
argv := append([]string{verb, str("node")}, splitModules(str("module"))...)
|
|
// A module known and not built: its build asked for, the assignment pending on it (novox/hq
|
|
// issue 325). unassign declares no build, so a call giving it is refused before this.
|
|
if verb == "assign" && on("build") {
|
|
argv = append(argv, "--build")
|
|
}
|
|
return argv, 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.
|
|
// A push through the seat is a push by hand, and says why (novox/hq to-be 45 §7).
|
|
if err := need("why"); err != nil {
|
|
return nil, fmt.Errorf("%w: a push by hand is a repair, recorded in the hand-act log with why", err)
|
|
}
|
|
why := []string{"--why", str("why")}
|
|
if c := str("cause"); c != "" {
|
|
why = append(why, "--cause", c)
|
|
}
|
|
if n := str("node"); n != "" {
|
|
// behind is not read here: given with a machine, it is refused as passed over — naming
|
|
// a machine and asking for every machine behind are two requests, and guessing one
|
|
// would push a machine nobody named, or not push one somebody did.
|
|
argv := []string{"push", n, "--wait", "0"}
|
|
// A running module's data moving is sent only when said, per module (novox/hq ADR 0217).
|
|
if m := str("move"); m != "" {
|
|
argv = append(argv, "--move", m)
|
|
}
|
|
return append(argv, why...), nil
|
|
}
|
|
// No machine: the whole mesh, whether or not behind said so. The command's answer says it
|
|
// first, so a caller who meant one machine reads that it was not one. move is not read: a
|
|
// machine whose data would move is held and named, and sent by a push that names it.
|
|
on("behind")
|
|
return append([]string{"push", "--behind", "--wait", "0"}, why...), nil
|
|
case "hand-act":
|
|
if err := need("what", "why", "cause"); err != nil {
|
|
return nil, err
|
|
}
|
|
argv := []string{"hand-act", "record", str("what"), "--why", str("why"), "--cause", str("cause")}
|
|
if c := str("condition"); c != "" {
|
|
argv = append(argv, "--condition", c)
|
|
}
|
|
return argv, nil
|
|
case "drill":
|
|
if err := need("what", "why"); err != nil {
|
|
return nil, err
|
|
}
|
|
argv := []string{"hand-act", "drill", str("what"), "--why", str("why")}
|
|
if c := str("condition"); c != "" {
|
|
argv = append(argv, "--condition", c)
|
|
}
|
|
return argv, nil
|
|
case "hand-acts":
|
|
argv := []string{"hand-acts", "--json"}
|
|
if d := str("days"); d != "" {
|
|
argv = append(argv, "--days", d)
|
|
}
|
|
return argv, nil
|
|
case "healers":
|
|
argv := []string{"healers", "--json"}
|
|
if d := str("days"); d != "" {
|
|
argv = append(argv, "--days", d)
|
|
}
|
|
return argv, nil
|
|
case "durations":
|
|
argv := []string{"durations", "--json"}
|
|
if k := str("kind"); k != "" {
|
|
argv = append(argv, "--kind", k)
|
|
}
|
|
if d := str("days"); d != "" {
|
|
argv = append(argv, "--days", d)
|
|
}
|
|
return argv, nil
|
|
case "conditions":
|
|
// One verb, four shapes, as `plans` (novox/hq to-be 45 §2): a silence, one condition, the
|
|
// history, or the open ones filtered.
|
|
if key := str("silence"); key != "" {
|
|
if err := need("for", "why"); err != nil {
|
|
return nil, err
|
|
}
|
|
argv := []string{"conditions", "silence", key, "--for", str("for"), "--why", str("why")}
|
|
if c := str("cause"); c != "" {
|
|
argv = append(argv, "--cause", c)
|
|
}
|
|
return argv, nil
|
|
}
|
|
if on("history") {
|
|
argv := []string{"conditions", "history", "--json"}
|
|
if d := str("days"); d != "" {
|
|
argv = append(argv, "--days", d)
|
|
}
|
|
if k := str("key"); k != "" {
|
|
argv = append(argv, "--key", k)
|
|
}
|
|
return argv, nil
|
|
}
|
|
if k := str("key"); k != "" {
|
|
return []string{"conditions", "show", k, "--json"}, nil
|
|
}
|
|
argv := []string{"conditions", "--json"}
|
|
for _, filter := range []string{"scope", "severity", "machine"} {
|
|
if v := str(filter); v != "" {
|
|
argv = append(argv, "--"+filter, v)
|
|
}
|
|
}
|
|
return argv, nil
|
|
case "retire":
|
|
answer := str("answer")
|
|
if answer == "" {
|
|
return []string{"retire", "--json"}, nil
|
|
}
|
|
if answer != "approve" && answer != "reject" {
|
|
return nil, fmt.Errorf("retire answers approve or reject, not %q", answer)
|
|
}
|
|
if err := need("node", "module", "why"); err != nil {
|
|
return nil, err
|
|
}
|
|
argv := []string{"retire", answer, str("node"), str("module"), "--why", str("why")}
|
|
if c := str("cause"); c != "" {
|
|
argv = append(argv, "--cause", c)
|
|
}
|
|
return argv, nil
|
|
case "cleanup":
|
|
consumer, older := str("consumer"), str("older-than")
|
|
switch {
|
|
case consumer != "" && older != "":
|
|
return nil, errors.New("cleanup deletes one consumer or those older than some days, not both")
|
|
case consumer != "":
|
|
if err := need("node", "module", "why"); err != nil {
|
|
return nil, err
|
|
}
|
|
argv := []string{"cleanup", "delete", str("node"), str("module"), consumer, "--why", str("why")}
|
|
if c := str("cause"); c != "" {
|
|
argv = append(argv, "--cause", c)
|
|
}
|
|
return argv, nil
|
|
case older != "":
|
|
if err := need("why"); err != nil {
|
|
return nil, err
|
|
}
|
|
argv := []string{"cleanup", "delete", "--older-than", older, "--why", str("why")}
|
|
if on("confirm") {
|
|
argv = append(argv, "--confirm")
|
|
}
|
|
if c := str("cause"); c != "" {
|
|
argv = append(argv, "--cause", c)
|
|
}
|
|
return argv, nil
|
|
}
|
|
return []string{"cleanup", "list", "--json"}, nil
|
|
// novox/hq ADR 0251.
|
|
case "artifacts":
|
|
argv := []string{"artifacts", "--json"}
|
|
if r := str("repository"); r != "" {
|
|
argv = append(argv, "--repository", r)
|
|
}
|
|
if on("collected") {
|
|
argv = append(argv, "--collected")
|
|
}
|
|
return argv, nil
|
|
case "collect":
|
|
argv := []string{"collect", "--json"}
|
|
if m := str("most"); m != "" {
|
|
argv = append(argv, "--most", m)
|
|
}
|
|
why := str("why")
|
|
if !on("confirm") {
|
|
if why != "" {
|
|
return nil, errors.New("collect takes why only with confirm: without confirm it is a dry run, " +
|
|
"and a reason for nothing would be recorded nowhere. Nothing was done")
|
|
}
|
|
return argv, nil
|
|
}
|
|
if err := need("why"); err != nil {
|
|
return nil, fmt.Errorf("%w: letting go of artifacts is a hand act, which says why. Nothing was done", err)
|
|
}
|
|
return append(argv, "--confirm", "--why", why), nil
|
|
case "images":
|
|
if err := need("node"); err != nil {
|
|
return nil, err
|
|
}
|
|
return []string{"images", str("node"), "--json"}, nil
|
|
case "mirrors":
|
|
// novox/hq ADR 0257.
|
|
argv := []string{"mirrors", "--json"}
|
|
record := str("record")
|
|
why := str("why")
|
|
if record == "" {
|
|
if on("confirm") || why != "" {
|
|
return nil, errors.New("mirrors takes confirm and why only with record: there is nothing " +
|
|
"else it records. Nothing was done")
|
|
}
|
|
return argv, nil
|
|
}
|
|
argv = append(argv, "--record", record)
|
|
if !on("confirm") {
|
|
if why != "" {
|
|
return nil, errors.New("mirrors takes why only with confirm: without confirm it is a dry run, " +
|
|
"and a reason for nothing would be recorded nowhere. Nothing was done")
|
|
}
|
|
return argv, nil
|
|
}
|
|
if err := need("why"); err != nil {
|
|
return nil, fmt.Errorf("%w: recording a copy as the mesh's is a hand act, which says why. Nothing was done", err)
|
|
}
|
|
return append(argv, "--confirm", "--why", why), nil
|
|
case "data":
|
|
argv := []string{"data", "--json"}
|
|
if m := str("machine"); m != "" {
|
|
argv = append(argv, "--machine", m)
|
|
}
|
|
if on("retired") {
|
|
argv = append(argv, "--retired")
|
|
}
|
|
return argv, nil
|
|
case "upgrade":
|
|
if on("backlog") {
|
|
return []string{"upgrade", "backlog"}, nil
|
|
}
|
|
if on("release-backlog") {
|
|
argv := []string{"upgrade", "release-backlog", "--why", str("why")}
|
|
if c := str("cause"); c != "" {
|
|
argv = append(argv, "--cause", c)
|
|
}
|
|
return argv, nil
|
|
}
|
|
m, policy := str("module"), str("policy")
|
|
if m == "" {
|
|
if policy != "" {
|
|
return nil, errors.New("upgrade takes a policy only for a module")
|
|
}
|
|
return []string{"upgrade"}, nil
|
|
}
|
|
argv := []string{"upgrade", m}
|
|
if policy != "" {
|
|
argv = append(argv, policy)
|
|
if on("together") {
|
|
argv = append(argv, "--together")
|
|
}
|
|
if w := str("why"); w != "" {
|
|
argv = append(argv, "--why", w)
|
|
}
|
|
}
|
|
return argv, nil
|
|
case "bus":
|
|
if !on("upgrade") {
|
|
return []string{"bus"}, nil
|
|
}
|
|
argv := []string{"bus", "upgrade", "--why", str("why")}
|
|
if c := str("cause"); c != "" {
|
|
argv = append(argv, "--cause", c)
|
|
}
|
|
if on("reversible") {
|
|
argv = append(argv, "--reversible")
|
|
}
|
|
if on("irreversible") {
|
|
argv = append(argv, "--irreversible")
|
|
}
|
|
if w := str("snapshot-taken"); w != "" {
|
|
argv = append(argv, "--snapshot-taken", w)
|
|
}
|
|
return argv, nil
|
|
case "doctor":
|
|
which := 0
|
|
argv := []string{"doctor"}
|
|
for _, sub := range []string{"run", "probes", "signals"} {
|
|
if on(sub) {
|
|
which++
|
|
argv = append(argv, sub)
|
|
}
|
|
}
|
|
if which > 1 {
|
|
return nil, errors.New("doctor answers one of run, probes or signals at a time")
|
|
}
|
|
if p := str("probe"); p != "" {
|
|
argv = append(argv, "--probe", p)
|
|
}
|
|
return append(argv, "--json"), nil
|
|
case "rotate":
|
|
if p := str("provision"); p != "" {
|
|
argv := []string{"rotate", p}
|
|
if c := str("consumer"); c != "" {
|
|
argv = append(argv, "--consumer", c)
|
|
}
|
|
// With a provision, module narrows to one consuming module (novox/hq issue 268); node
|
|
// and secret stay the other shape's, and are refused as passed over.
|
|
if m := str("module"); m != "" {
|
|
argv = append(argv, "--module", m)
|
|
}
|
|
return argv, nil
|
|
}
|
|
_, node := a.given["node"]
|
|
_, module := a.given["module"]
|
|
_, secret := a.given["secret"]
|
|
if node || module || secret {
|
|
if err := need("node", "module", "secret"); err != nil {
|
|
return nil, fmt.Errorf("%w: a module's own secret is named by node, module and secret together", err)
|
|
}
|
|
argv := []string{"secret", "rotate", str("node"), str("module"), str("secret")}
|
|
// Why, recorded in the hand-act log (novox/hq ADR 0228); a cause only beside a why.
|
|
if w := str("why"); w != "" {
|
|
argv = append(argv, "--why", w)
|
|
if c := str("cause"); c != "" {
|
|
argv = append(argv, "--cause", c)
|
|
}
|
|
}
|
|
return argv, nil
|
|
}
|
|
// Neither shape: the command says its usage, which names both, and that is the answer the
|
|
// caller needs.
|
|
return []string{"rotate"}, nil
|
|
case "token":
|
|
// `token issue` at a shell (novox/hq ADR 0169). Exactly one of node or new; the command
|
|
// refuses both or neither in its own words.
|
|
argv := []string{"token", "issue"}
|
|
if n := str("node"); n != "" {
|
|
argv = append(argv, "--node", n)
|
|
}
|
|
if n := str("new"); n != "" {
|
|
argv = append(argv, "--new", n)
|
|
}
|
|
if k := str("overlay_key"); k != "" {
|
|
argv = append(argv, "--overlay-key", k)
|
|
}
|
|
if d := str("for"); d != "" {
|
|
argv = append(argv, "--for", d)
|
|
}
|
|
if str("adopted") == "true" {
|
|
argv = append(argv, "--adopted")
|
|
}
|
|
return argv, nil
|
|
case "settings":
|
|
// Every module's preferences, their defaults and each machine's value (novox/hq ADR 0262):
|
|
// the one interface for them, so no module builds a settings tool of its own. Asked for by
|
|
// name, or by naming no module, since a layer is always some module's.
|
|
if str("module") == "" && str("values") != "" {
|
|
return nil, errors.New("settings: a module is needed to set values; name it with module")
|
|
}
|
|
if str("module") == "" && on("clear") {
|
|
return nil, errors.New("settings: a module is needed to clear a layer; name it with module")
|
|
}
|
|
if list := str("list"); list != "" || str("module") == "" {
|
|
if list != "" && list != "preferences" {
|
|
return nil, fmt.Errorf("settings lists %q only; %q is not a listing", "preferences", list)
|
|
}
|
|
argv := []string{"settings", "preferences"}
|
|
if m := str("module"); m != "" {
|
|
argv = append(argv, m)
|
|
}
|
|
if n := str("node"); n != "" {
|
|
argv = append(argv, "--node", n)
|
|
}
|
|
return argv, nil
|
|
}
|
|
// `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
|
|
}
|
|
var argv []string
|
|
switch {
|
|
case on("clear"):
|
|
argv = []string{"settings", "clear", str("module")}
|
|
case str("values") != "":
|
|
argv = []string{"settings", "set", str("module"), str("values")}
|
|
// What a set removes is refused unless meant (novox/hq ADR 0217).
|
|
if on("replace") {
|
|
argv = append(argv, "--replace")
|
|
}
|
|
default:
|
|
// Neither values nor clear: the layer as it stands, which is what a caller reads before
|
|
// replacing it (novox/hq ADR 0217) — and with history, the layers it replaced.
|
|
argv = []string{"settings", "show", str("module")}
|
|
if on("history") {
|
|
argv = append(argv, "--history")
|
|
}
|
|
}
|
|
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":
|
|
// The command's three shapes (`build --on`, `build --behind`, `build <repository>`), one per
|
|
// call: each branch reads only its own argument, so another given beside it is refused as
|
|
// passed over rather than dropped. Asked and not waited for, the same as a single build.
|
|
if b := str("on"); b != "" {
|
|
return []string{"build", "--on", b, "--wait", "0"}, nil
|
|
}
|
|
if on("behind") {
|
|
return []string{"build", "--behind", "--wait", "0"}, nil
|
|
}
|
|
if a.given["repository"] == "" {
|
|
// No shape named: the command says its usage, which names all three — the answer the
|
|
// caller needs, and the same as `rotate` given neither of its shapes.
|
|
return []string{"build"}, nil
|
|
}
|
|
// 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 a verb of the %s seat's table that this binary has no command line for",
|
|
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, "collection": true,
|
|
"hand-acts": true, "durations": true, "conditions": true, "doctor": true, "retire": true, "cleanup": true, "data": true,
|
|
// What the records say the registries may keep (novox/hq ADR 0251).
|
|
"artifacts": true, "collect": true, "images": true, "mirrors": true,
|
|
// The delivery's owner's verbs answer JSON where they read (plan, order, check, walks) — novox/hq ADR 0239.
|
|
"delivery": true}
|
|
|
|
// overviewVerbs are the JSON verbs whose answer is said once, as data, and not again as the text the
|
|
// command printed: the overviews, which grow with the mesh (novox/hq issue 314). Every reader of theirs
|
|
// reads `answer` first.
|
|
var overviewVerbs = map[string]bool{"status": true, "conditions": true, "doctor": true}
|
|
|
|
// repairingCommand names a command line that repairs by hand, and so says why: a push, a plan stopped
|
|
// or closed, a consumer re-made (novox/hq to-be 45 §7). Empty for any other.
|
|
func repairingCommand(argv []string) string {
|
|
switch {
|
|
case argv[0] == "push":
|
|
return "push"
|
|
case argv[0] == "plans" && len(argv) > 1 && (argv[1] == "stop" || argv[1] == "close" || argv[1] == "go"):
|
|
return "plans " + argv[1]
|
|
case argv[0] == "broker" && len(argv) > 1 && argv[1] == "consumer-reset":
|
|
return "broker consumer-reset"
|
|
case argv[0] == "hand-act" && len(argv) > 1 && argv[1] == "drill":
|
|
return "hand-act drill"
|
|
case argv[0] == "hand-act":
|
|
return "hand-act record"
|
|
case argv[0] == "conditions" && len(argv) > 1 && argv[1] == "silence":
|
|
return "conditions silence"
|
|
case argv[0] == "retire" && len(argv) > 1 && (argv[1] == "approve" || argv[1] == "reject"):
|
|
return "retire " + argv[1]
|
|
case argv[0] == "cleanup" && len(argv) > 1 && argv[1] == "delete":
|
|
return "cleanup delete"
|
|
case argv[0] == "collect" && slices.Contains(argv, "--confirm"):
|
|
return "collect"
|
|
case argv[0] == "mirrors" && slices.Contains(argv, "--confirm"):
|
|
return "mirrors"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func isWhyFlag(word string) bool {
|
|
return word == "--why" || word == "-why" || strings.HasPrefix(word, "--why=") || strings.HasPrefix(word, "-why=")
|
|
}
|
|
|
|
// commandEnvironment is the environment of a command line this controller runs for someone: its own — the
|
|
// stores' credentials, the bus, the broker, everything a command run from a shell beside it would have, because it
|
|
// is that — with who asked, and the verb it came through. An empty verb is the controller's terminal (novox/hq ADR
|
|
// 0272 §4): no `MESH_VERB` at all, whatever this process was started with.
|
|
//
|
|
// The terminal's line is also not the serving controller's (servedVar), and carries cliTerminalVar, so what reads
|
|
// whether it was started at the terminal (startedAtTheTerminal) reads yes; every other line is stripped of that mark.
|
|
func commandEnvironment(caller, verb string) []string {
|
|
env := make([]string, 0, len(os.Environ())+3)
|
|
for _, kv := range os.Environ() {
|
|
if strings.HasPrefix(kv, verbVar+"=") || strings.HasPrefix(kv, link.CallerVar+"=") ||
|
|
strings.HasPrefix(kv, cliTerminalVar+"=") || (verb == "" && strings.HasPrefix(kv, servedVar+"=")) {
|
|
continue
|
|
}
|
|
env = append(env, kv)
|
|
}
|
|
env = append(env, link.CallerVar+"="+caller)
|
|
if verb != "" {
|
|
env = append(env, verbVar+"="+verb)
|
|
} else {
|
|
env = append(env, cliTerminalVar+"=1")
|
|
}
|
|
return env
|
|
}
|
|
|
|
// runVerb runs this binary with the given command line and gathers what it said.
|
|
//
|
|
// **This binary is the image this process runs, never the file at the path it started from**
|
|
// (novox/hq issue 289): the node-engine's witness moves a running build aside when it places the next
|
|
// one, into a directory only it may enter, and deletes it once the next is proved — while this process
|
|
// may still be serving. A verb run from the path then failed with "permission denied" for the seconds
|
|
// the old controller still answered. selfCommand runs what this process is running, wherever its file
|
|
// went; a verb it still cannot start is refused as a handover, which the caller asks again.
|
|
func runVerb(ctx context.Context, argv []string) (verbAnswer, error) {
|
|
if handingOver.Load() {
|
|
return verbAnswer{}, fmt.Errorf("%w: this controller is stopping and runs no new command", link.ErrHandingOver)
|
|
}
|
|
cmd := selfCommand(ctx, argv)
|
|
// And who asked, so an act it does by hand is recorded as theirs (novox/hq to-be 45 §7).
|
|
caller := link.CallerIn(ctx)
|
|
if caller == "" {
|
|
caller = "a seat call whose caller the bus did not name"
|
|
}
|
|
// And which verb, so the connection it dials says so in the bus's list (novox/hq issue 327).
|
|
verb, _ := ctx.Value(verbKey{}).(string)
|
|
if verb == "" {
|
|
verb = argv[0]
|
|
}
|
|
cmd.Env = commandEnvironment(caller+", through the "+catalogue.ControllerSeatName+" seat", verb)
|
|
// 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 := answerOf(argv, stdout.Bytes(), stderr.String(), runErr == nil)
|
|
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.
|
|
if errors.Is(runErr, os.ErrPermission) || errors.Is(runErr, os.ErrNotExist) {
|
|
// Its own image unreachable: a build replaced under a process that has not yet stopped.
|
|
// Refused as a handover, so the caller asks the controller that follows.
|
|
return answer, fmt.Errorf("%w: could not run %s from this controller's own build: %v",
|
|
link.ErrHandingOver, strings.Join(argv, " "), runErr)
|
|
}
|
|
return answer, fmt.Errorf("could not run %s: %w", strings.Join(argv, " "), runErr)
|
|
}
|
|
return answer, nil
|
|
}
|
|
|
|
// answerOf is what a command said, as a verb answers it: both streams as text, and standard output as data
|
|
// where the command speaks JSON.
|
|
func answerOf(argv []string, stdout []byte, stderr string, ok bool) verbAnswer {
|
|
answer := verbAnswer{Output: string(stdout) + stderr, OK: ok}
|
|
if jsonVerbs[argv[0]] && ok {
|
|
var parsed any
|
|
if json.Unmarshal(bytes.TrimSpace(stdout), &parsed) == nil {
|
|
answer.Answer = parsed
|
|
if overviewVerbs[argv[0]] {
|
|
// Once, as data: the same document again as text doubled an answer that already
|
|
// outgrew one message of the bus (novox/hq issue 314).
|
|
answer.Output = stderr + "its answer, as data, is `answer`\n"
|
|
}
|
|
}
|
|
}
|
|
return answer
|
|
}
|
|
|
|
// readHere answers a verb that only reads the bus in the serving controller itself, on its own connection
|
|
// and keeper (novox/hq issue 327): the same command, writing to the answer rather than to a process's
|
|
// output, so the answer is the one the command prints. False for any other command line, which runs as a
|
|
// command of its own. Every `conditions` call — the operator's channel reads it at least once a minute —
|
|
// was a process that dialled the bus, logged in and left.
|
|
func readHere(ctx context.Context, argv []string) (verbAnswer, bool) {
|
|
var read func(context.Context, []string, io.Writer) error
|
|
args := argv[1:]
|
|
// Each where this process holds what it reads: the serving keeper, the hand-act log's connection, the
|
|
// serving connection.
|
|
switch argv[0] {
|
|
case "conditions":
|
|
sub := "list"
|
|
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
|
|
sub, args = args[0], args[1:]
|
|
}
|
|
if conditionsFrom != nil {
|
|
read = map[string]func(context.Context, []string, io.Writer) error{
|
|
"list": listConditions, "show": showCondition, "history": conditionHistory}[sub]
|
|
}
|
|
case "hand-acts":
|
|
if handActConn != nil || servingBus.Load() != nil {
|
|
read = listHandActs
|
|
}
|
|
case "queue":
|
|
if servingBus.Load() != nil {
|
|
read = listQueue
|
|
}
|
|
}
|
|
if read == nil {
|
|
return verbAnswer{}, false
|
|
}
|
|
var out bytes.Buffer
|
|
stderr := ""
|
|
err := read(ctx, args, &out)
|
|
if err != nil {
|
|
// As the command says it when it fails (main).
|
|
stderr = "mesh-controller: " + err.Error() + "\n"
|
|
}
|
|
return answerOf(argv, out.Bytes(), stderr, err == nil), true
|
|
}
|
|
|
|
// 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. A verb it carries that this binary
|
|
// cannot run is named at start and answers the reason when called — never a refusal to serve, which
|
|
// would take the whole control plane down for one word (novox/hq ADR 0185).
|
|
func seatToolHandlers() (map[string]link.ToolHandler, []string, error) {
|
|
seat, known := catalogue.SeatNamed(catalogue.ControllerSeatName)
|
|
if !known {
|
|
return nil, nil, fmt.Errorf("this mesh defines no %s seat", catalogue.ControllerSeatName)
|
|
}
|
|
var behind []string
|
|
handlers := map[string]link.ToolHandler{}
|
|
for _, v := range seat.Serves {
|
|
verb := v.Name
|
|
if inProcess[verb] {
|
|
handlers[verb] = func(ctx context.Context, raw json.RawMessage) (any, error) {
|
|
args := map[string]any{}
|
|
if len(bytes.TrimSpace(raw)) > 0 {
|
|
if err := json.Unmarshal(raw, &args); err != nil {
|
|
return nil, fmt.Errorf("the arguments are not a JSON object: %w", err)
|
|
}
|
|
}
|
|
// Refused like any verb's: what a verb does not take is not ignored.
|
|
a, err := readArguments(verb, args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if verb == "calls" {
|
|
return callsAnswer(link.Calls, a.given["call"])
|
|
}
|
|
if verb == "dead-letters" {
|
|
return deadLettersAnswer(ctx, a)
|
|
}
|
|
if verb == "doctor" {
|
|
// From the serving controller, which runs the self-check and hears the signals
|
|
// (novox/hq to-be 45 §4): the last verdict at once, or a run now.
|
|
argv, err := a.commandLine()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sub := ""
|
|
if len(argv) > 2 && !strings.HasPrefix(argv[1], "-") {
|
|
sub = argv[1]
|
|
}
|
|
return doctorAnswer(ctx, sub, a.given["probe"])
|
|
}
|
|
return seatTools(), nil
|
|
}
|
|
continue
|
|
}
|
|
var policy *heldAtTheTerminal
|
|
if _, err := argvFor(verb, sampleArguments(v)); err != nil && !errors.As(err, &policy) {
|
|
// **A row ahead of this binary is not a reason to go silent.**
|
|
//
|
|
// The row is the store's and a control plane follows it (novox/hq ADR 0154), so a verb
|
|
// this build does not know means the row was widened by a newer one — the ordinary
|
|
// state of a roll-out, and of a push that put an older control plane back. Refusing to
|
|
// serve at all made that transient fatal: on 2026-10-02 one unknown verb took the whole
|
|
// mesh off the bus for ten minutes, and the way back was a human running the binary by
|
|
// hand, because the thing that would have repaired it is the thing that was down
|
|
// (novox/hq 04-ISSUES/201, ADR 0185).
|
|
//
|
|
// So the verbs this binary knows are served, and this one answers the reason instead of
|
|
// nothing: a caller gets a sentence naming the fault, and everything else keeps working
|
|
// — including the push that replaces this binary with the one whose verb it is.
|
|
behind = append(behind, verb)
|
|
reason := err
|
|
handlers[verb] = func(context.Context, json.RawMessage) (any, error) {
|
|
return nil, fmt.Errorf("%s is in this mesh's %s row and the control plane running "+
|
|
"here cannot run it: %w. It is a verb of a newer build; this one is behind",
|
|
verb, catalogue.ControllerSeatName, reason)
|
|
}
|
|
continue
|
|
}
|
|
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
|
|
}
|
|
ctx = context.WithValue(ctx, verbKey{}, verb)
|
|
if verb == "status" && statusFrom != nil {
|
|
// At once, from the summary the serving controller keeps (novox/hq to-be 45 Phase 0).
|
|
return statusFrom.answer(ctx)
|
|
}
|
|
if !readingVerbs[verb] && !(verb == "plans" && !actsOnAPlan(args)) {
|
|
// Whatever it did, `status` is composed again once it has.
|
|
defer statusFrom.nudge()
|
|
}
|
|
if answer, read := readHere(ctx, argv); read {
|
|
return answer, nil
|
|
}
|
|
if answersFirst(argv) {
|
|
// Before anything is sent: a push sends the bus's own machine first, and a broker
|
|
// reloading its user list forgets the answer it was about to permit (novox/hq issue 265).
|
|
link.Acknowledge(ctx)
|
|
}
|
|
return runVerb(ctx, argv)
|
|
}
|
|
}
|
|
return handlers, behind, nil
|
|
}
|
|
|
|
// actsOnAPlan is `plans` asked to stop, close or retry one rather than to show them.
|
|
func actsOnAPlan(args map[string]any) bool {
|
|
for _, act := range []string{"stop", "close", "retry"} {
|
|
if v, _ := args[act].(string); strings.TrimSpace(v) != "" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// inProcess are the verbs answered by this process rather than by a command it runs: `tools` from
|
|
// the records, `calls` from what this process served.
|
|
var inProcess = map[string]bool{"tools": true, "calls": true, "doctor": true,
|
|
// What a consumer gave up on, read and changed on the serving controller's own connection (novox/hq
|
|
// issue 330).
|
|
"dead-letters": true}
|
|
|
|
// answersFirst is a command line whose caller is answered before it runs: a push, by its verb or
|
|
// through `command`. A push sends the machine holding the bus first when its user list changed, the
|
|
// broker reloads, and a reload forgets every answer the bus was about to permit — so an answer
|
|
// waiting for the push to end was refused, every time the list had changed (novox/hq issue 265).
|
|
func answersFirst(argv []string) bool {
|
|
return len(argv) > 0 && argv[0] == "push"
|
|
}
|
|
|
|
// callsAnswer is what `calls` answers: the kept calls, newest first, without their answers — or
|
|
// one call whole. Kept on the bus, so a call a controller before this one served is answered too
|
|
// (novox/hq to-be 45 §6); where the bus cannot be read, what this process served is answered and
|
|
// the reason said beside it.
|
|
func callsAnswer(log *link.CallLog, id string) (any, error) {
|
|
if id != "" {
|
|
c, ok, err := log.Shown(id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("call %s is not in this controller's memory, and the calls kept on the "+
|
|
"bus could not be read: %w", id, err)
|
|
}
|
|
if !ok {
|
|
if log.IsDurable() {
|
|
return nil, fmt.Errorf("no call %s is kept: the bus keeps the last %d calls, or %s, and this "+
|
|
"is not among them — `calls` lists them", id, broker.KeptCallsDurably, broker.CallsKeptFor)
|
|
}
|
|
return nil, fmt.Errorf("no call %s is kept here: calls are kept by the controller that "+
|
|
"answered them, the last %d, and not across a restart — `calls` lists them", id, link.KeptCalls)
|
|
}
|
|
return c, nil
|
|
}
|
|
recent, err := log.Recent()
|
|
for i := range recent {
|
|
recent[i].Answer = nil
|
|
}
|
|
answer := map[string]any{"calls": recent, "note": "newest first; `calls` with a call's id gives its whole answer"}
|
|
if log.IsDurable() {
|
|
answer["kept"] = fmt.Sprintf("the last %d calls, or %s, on the bus — across a restart of the controller",
|
|
broker.KeptCallsDurably, broker.CallsKeptFor)
|
|
} else {
|
|
answer["kept"] = fmt.Sprintf("the last %d calls this controller served, in its memory only", link.KeptCalls)
|
|
}
|
|
if err != nil {
|
|
answer["unread"] = err.Error()
|
|
}
|
|
return answer, 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 {
|
|
tool := map[string]any{
|
|
"name": v.Name, "description": v.Description, "input": v.Input, "output": v.Output,
|
|
}
|
|
// What the verb is the mesh's way to do (novox/hq ADR 0245): read by the console's search and
|
|
// the agent's instructions.
|
|
if len(v.Replaces) > 0 {
|
|
tool["replaces"] = v.Replaces
|
|
}
|
|
tools = append(tools, tool)
|
|
}
|
|
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 — and nothing
|
|
// more, since an argument a verb does not declare is refused.
|
|
func sampleArguments(v catalogue.Verb) map[string]any {
|
|
sample := map[string]any{}
|
|
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
|
|
}
|
|
|
|
// seatAnnouncement is what the controller says it serves on the bus (novox/hq ADR 0197): the
|
|
// mesh-controller seat, one endpoint per verb it answers, each with the seat's own description and
|
|
// argument schema — the same facts `tools` answers from the records, as NATS's services format.
|
|
func seatAnnouncement(handlers map[string]link.ToolHandler) micro.Info {
|
|
about := map[string]catalogue.Verb{}
|
|
for _, s := range catalogue.SeatsWithAProtocol() {
|
|
if s.Name == catalogue.ControllerSeatName {
|
|
for _, v := range s.Serves {
|
|
about[v.Name] = v
|
|
}
|
|
}
|
|
}
|
|
verbs := make([]string, 0, len(handlers))
|
|
for verb := range handlers {
|
|
verbs = append(verbs, verb)
|
|
}
|
|
sort.Strings(verbs)
|
|
var endpoints []micro.EndpointInfo
|
|
for _, verb := range verbs {
|
|
schema, _ := json.Marshal(about[verb].Input)
|
|
// The same shape every tool runtime announces in (node-tools' announce package): the name is
|
|
// `<seat>__<verb>`, as the protocol's characters allow; the metadata is what identifies it.
|
|
endpoints = append(endpoints, micro.EndpointInfo{
|
|
Name: catalogue.ControllerSeatName + "__" + verb,
|
|
Subject: link.SeatToolSubject(catalogue.ControllerSeatName, verb),
|
|
QueueGroup: "seat." + catalogue.ControllerSeatName,
|
|
Metadata: map[string]string{
|
|
"kind": "seat", "module": catalogue.ControllerSeatName, "tool": verb,
|
|
"seat": catalogue.ControllerSeatName, "scope": "mesh", "interchangeable": "false",
|
|
"description": about[verb].Description, "schema": string(schema),
|
|
},
|
|
})
|
|
}
|
|
return micro.Info{
|
|
ServiceIdentity: micro.ServiceIdentity{
|
|
Name: catalogue.ControllerSeatName, ID: "controller", Version: "0.1.0",
|
|
Metadata: map[string]string{"seat": catalogue.ControllerSeatName, "scope": "mesh"},
|
|
},
|
|
Description: "the mesh's own verbs, answered by the holder of the mesh-controller seat",
|
|
Endpoints: endpoints,
|
|
}
|
|
}
|
|
|
|
// nodeReads are the `node` subcommands a verb may run: the ones that only read.
|
|
var nodeReads = map[string]bool{"list": true, "show": true}
|
|
|
|
// flagsOnly says a command line's rest names no subcommand: empty, or beginning with a flag. For a command
|
|
// with no subcommands every word is a flag, its value or a name it reads.
|
|
func flagsOnly(rest []string) bool { return len(rest) == 0 || strings.HasPrefix(rest[0], "-") }
|
|
|
|
// subIn says the rest begins with one of these subcommands.
|
|
func subIn(rest []string, subs ...string) bool {
|
|
return len(rest) > 0 && slices.Contains(subs, rest[0])
|
|
}
|
|
|
|
// commandReadForms are the command lines the generic `command` verb may run (novox/hq ADR 0266): **an allow
|
|
// list of the ones that only read**, judged command by command. Anything else — every command that writes a
|
|
// record, sends, builds, issues an account or a token, sets a key, accepts, rotates, recovers or exports a
|
|
// secret — is refused, and a command added later is refused until it is judged a read. Writing has its named
|
|
// verbs, which compose their own lines and are judged by terminalOnly; the rest is the operator's at the
|
|
// controller's terminal.
|
|
var commandReadForms = map[string]func(rest []string) bool{
|
|
"status": flagsOnly, "version": flagsOnly, "help": flagsOnly, "seats": flagsOnly, "healers": flagsOnly,
|
|
"hand-acts": flagsOnly, "durations": flagsOnly, "collection": flagsOnly, "images": flagsOnly,
|
|
"artifacts": flagsOnly, "data": flagsOnly, "builds": flagsOnly, "queue": flagsOnly,
|
|
// `plan <node>` previews a node's declaration; it sends nothing.
|
|
"plan": func([]string) bool { return true },
|
|
// `plans` lists and `plans <id>` shows one; `plans stop|close|go` acts.
|
|
// Judged on every word, not the first: a flag before the subcommand (`plans --json go <id>`) still acts.
|
|
"plans": func(r []string) bool {
|
|
return !slices.ContainsFunc(r, func(w string) bool { return slices.Contains(plansActs, w) })
|
|
},
|
|
// `doctor` answers the last run, `probes` and `signals` describe; `doctor run` runs.
|
|
"doctor": func(r []string) bool { return flagsOnly(r) || subIn(r, "probes", "signals") },
|
|
"conditions": func(r []string) bool { return flagsOnly(r) || subIn(r, "list", "show", "history") },
|
|
"node": func(r []string) bool { return subIn(r, "list", "show") },
|
|
"module": func(r []string) bool { return subIn(r, "list") },
|
|
"settings": func(r []string) bool { return subIn(r, "show", "preferences") },
|
|
"retire": func(r []string) bool { return subIn(r, "list") },
|
|
"cleanup": func(r []string) bool { return subIn(r, "list") },
|
|
"delivery": func(r []string) bool { return subIn(r, "plan", "walks") },
|
|
// `bus` alone says the bus's step; `bus upgrade` takes one.
|
|
"bus": func(r []string) bool { return len(r) == 0 },
|
|
// `mirrors` lists; --record and --confirm keep a mirror.
|
|
"mirrors": func(r []string) bool {
|
|
return flagsOnly(r) && !slices.ContainsFunc(r, func(w string) bool {
|
|
return w == "--record" || w == "-record" || strings.HasPrefix(w, "--record=") || strings.HasPrefix(w, "-record=") ||
|
|
w == "--confirm" || w == "-confirm" || strings.HasPrefix(w, "--confirm=")
|
|
})
|
|
},
|
|
}
|
|
|
|
// plansActs are the `plans` subcommands that act on a walk; no other word of a plans line is one of them.
|
|
var plansActs = []string{"go", "stop", "close", "retry"}
|
|
|
|
// heldAtTheTerminal is a refusal of policy (novox/hq ADR 0266): the verb is known and served, and this line is
|
|
// the operator's at the controller's terminal. Never read as a verb this binary is behind on.
|
|
type heldAtTheTerminal struct{ msg string }
|
|
|
|
func (e *heldAtTheTerminal) Error() string { return e.msg }
|
|
|
|
func terminalRefusal(format string, args ...any) error {
|
|
return &heldAtTheTerminal{fmt.Sprintf(format, args...)}
|
|
}
|
|
|
|
// commandReads refuses a line the generic verb may not run, saying what it may.
|
|
func commandReads(argv []string) error {
|
|
if read, ok := commandReadForms[argv[0]]; ok && read(argv[1:]) {
|
|
return nil
|
|
}
|
|
return terminalRefusal("%q is not a reading command, and the generic command verb only reads (novox/hq ADR 0266): "+
|
|
"whoever may call a verb includes agents, and a line that writes, issues, sets a key or reveals a secret "+
|
|
"would be theirs to run. Use the named verb for it, or run it at the controller's terminal. The verb may "+
|
|
"run: %s. Nothing was done", strings.Join(argv, " "), commandReadNames())
|
|
}
|
|
|
|
func commandReadNames() string {
|
|
names := make([]string, 0, len(commandReadForms))
|
|
for n := range commandReadForms {
|
|
names = append(names, n)
|
|
}
|
|
sort.Strings(names)
|
|
return strings.Join(names, ", ") + " (each in its reading forms)"
|
|
}
|
|
|
|
// terminalOnlyCommands are the commands no verb runs, whatever composed them (novox/hq ADR 0266): they set
|
|
// the operator's key, issue a credential or a token that is answered to the caller, or accept, recover or
|
|
// export a secret. Their answers or effects hand whoever calls them what the runtime's account holds.
|
|
var terminalOnlyCommands = map[string]string{
|
|
"operator": "the operator's key and credential",
|
|
"identity": "the mesh's identity keys",
|
|
"token": "a token a machine joins with, answered to the caller",
|
|
"broker": "the bus's accounts",
|
|
"api": "the controller's API keys",
|
|
"licence": "the licences' secrets",
|
|
}
|
|
|
|
// terminalOnly refuses, through any verb, a command that is the operator's at the controller's terminal
|
|
// alone (novox/hq ADR 0266). **Every `node` subcommand that is not a read**: `node account` and
|
|
// `node agent-account` above all. Whoever may call a verb includes agents, and an agent that named itself
|
|
// the operator account, or cleared the agent account, would have the next send grant it root through the
|
|
// sudo module's rule. An allow list, so a subcommand added later is refused until it is judged a read.
|
|
func terminalOnly(argv []string) error {
|
|
if len(argv) == 0 {
|
|
return nil
|
|
}
|
|
if what, kept := terminalOnlyCommands[argv[0]]; kept {
|
|
return terminalRefusal("%s is run at the controller's terminal only, never through a verb: it holds %s, and "+
|
|
"whoever may call a verb includes agents (novox/hq ADR 0266). Nothing was done", argv[0], what)
|
|
}
|
|
// Of a secret's commands only rotation, which seals the new value to the machine that uses it.
|
|
if argv[0] == "secret" && (len(argv) < 2 || argv[1] != "rotate") {
|
|
return terminalRefusal("secret %s is run at the controller's terminal only, never through a verb: accepting, "+
|
|
"recovering or exporting a secret hands it to whoever asks, and that includes agents (novox/hq ADR "+
|
|
"0266). Nothing was done", strings.Join(argv[1:], " "))
|
|
}
|
|
if argv[0] != "node" {
|
|
return nil
|
|
}
|
|
if len(argv) > 1 && nodeReads[argv[1]] {
|
|
return nil
|
|
}
|
|
sub := "node"
|
|
if len(argv) > 1 {
|
|
sub += " " + argv[1]
|
|
}
|
|
return terminalRefusal("%s is run at the controller's terminal only, never through a verb: a node's accounts "+
|
|
"decide who may become root on it (novox/hq ADR 0266). A verb may run node list and node show. "+
|
|
"Nothing was done", sub)
|
|
}
|