`secret rotate <node> <module> <name>` makes the secret anew the way the first mint did, seals it
to the machine and the operator, and sends the machine, so the module starts again on the new value
— said in the log with who asked and when, never the value. Only for a secret whose definition says
`"taken": "at-start"`: an own secret is now a path, or {path, taken}, and a definition that says
nothing of how a secret is taken is refused with the word to write, because a credential rotated
under software that never reads it again is worse than one left alone (issue 179). `applied` is
refused by name until the staged form ADR 0114 decided is built; a value given to the mesh is
refused as ADR 0113 says. `rotate` is a verb on the controller's seat with two shapes — a pair
credential by provision, an own secret by machine, module and name — so the console can ask.
Registered manifests keep their bytes: a path alone is written back as a path.
218 lines
7.9 KiB
Go
218 lines
7.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 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 "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 "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 "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, 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}
|
|
}
|