Merge pull request 'Answer mesh-cli: the control-node's operator is the terminal, everyone else is not (hq ADR 0272)' (#176) from feat/272-answer-mesh-cli into main
This commit was merged in pull request #176.
This commit is contained in:
@@ -51,6 +51,9 @@ func assignWith(ctx context.Context, open *stores, node string, opts assignOptio
|
|||||||
if len(modules) == 0 {
|
if len(modules) == 0 {
|
||||||
return "", fmt.Errorf("assign %s names no module", node)
|
return "", fmt.Errorf("assign %s names no module", node)
|
||||||
}
|
}
|
||||||
|
if err := refusedMovingTheController(modules); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
// Held while it is recorded, so it cannot land between a converge's preview and its flip and
|
// Held while it is recorded, so it cannot land between a converge's preview and its flip and
|
||||||
// be taken without ever having been previewed (novox/hq ADR 0100).
|
// be taken without ever having been previewed (novox/hq ADR 0100).
|
||||||
ctx, release, err := holdNodes(ctx, open, []string{node})
|
ctx, release, err := holdNodes(ctx, open, []string{node})
|
||||||
@@ -209,6 +212,9 @@ func seatDependenciesOnAssign(ctx context.Context, open *stores, node string, mo
|
|||||||
// (novox/hq ADR 0207) — the other side of refusing that module's assignment without one. Several
|
// (novox/hq ADR 0207) — the other side of refusing that module's assignment without one. Several
|
||||||
// modules in one act are judged together, so a holder and its dependents come off in one command.
|
// modules in one act are judged together, so a holder and its dependents come off in one command.
|
||||||
func unassign(ctx context.Context, open *stores, node string, modules ...string) (string, error) {
|
func unassign(ctx context.Context, open *stores, node string, modules ...string) (string, error) {
|
||||||
|
if err := refusedMovingTheController(modules); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
if len(modules) == 0 {
|
if len(modules) == 0 {
|
||||||
return "", fmt.Errorf("unassign %s names no module", node)
|
return "", fmt.Errorf("unassign %s names no module", node)
|
||||||
}
|
}
|
||||||
@@ -360,3 +366,17 @@ func issueOnAssign(ctx context.Context, open *stores, node, module string) strin
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("its bus credential is issued and sealed to %s, and arrives with the push", node)
|
return fmt.Sprintf("its bus credential is issued and sealed to %s, and arrives with the push", node)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refusedMovingTheController refuses assigning or unassigning the controller's module through a verb (review of
|
||||||
|
// novox/hq ADR 0272): the node it is assigned to is the control-node, and the control-node's operator account is
|
||||||
|
// the controller's terminal, so whoever moves the module chooses the terminal. At the terminal alone, as every
|
||||||
|
// change that says who may change what.
|
||||||
|
func refusedMovingTheController(modules []string) error {
|
||||||
|
verb, through := throughAVerb()
|
||||||
|
if !through || !slices.Contains(modules, controllerModule) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s is assigned and unassigned at the controller's terminal only (mesh-cli on the control-node), "+
|
||||||
|
"never through a verb (this came through %q): the node it runs on is the control-node, whose operator is the "+
|
||||||
|
"terminal (novox/hq ADR 0272). Nothing was assigned", controllerModule, verb)
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,10 +47,20 @@ const servedVar = "MESH_SERVED_BY_THE_CONTROLLER"
|
|||||||
// not anything it started, not a verb's command, not a seat call's. The serving controller marks its own
|
// not anything it started, not a verb's command, not a seat call's. The serving controller marks its own
|
||||||
// environment (servedVar), so a build asked in it, or by any process it starts, never reads as the terminal's;
|
// environment (servedVar), so a build asked in it, or by any process it starts, never reads as the terminal's;
|
||||||
// runVerb also names the verb and the caller.
|
// runVerb also names the verb and the caller.
|
||||||
|
//
|
||||||
|
// **And a line mesh-cli asked as the controller's terminal** (novox/hq ADR 0272 §4): the serving controller runs it
|
||||||
|
// without the served mark and without a verb, names its caller, and marks it with cliTerminalVar — a mark only the
|
||||||
|
// mesh-cli path sets and every other command line the controller runs is stripped of (commandEnvironment).
|
||||||
func startedAtTheTerminal() bool {
|
func startedAtTheTerminal() bool {
|
||||||
return os.Getenv(servedVar) == "" && os.Getenv(verbVar) == "" && os.Getenv(link.CallerVar) == ""
|
if os.Getenv(servedVar) != "" || os.Getenv(verbVar) != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return os.Getenv(link.CallerVar) == "" || os.Getenv(cliTerminalVar) != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cliTerminalVar marks a command line the serving controller runs as the controller's terminal for mesh-cli.
|
||||||
|
const cliTerminalVar = "MESH_CLI_TERMINAL"
|
||||||
|
|
||||||
// markServed marks this process, and so everything it starts, as the serving controller's.
|
// markServed marks this process, and so everything it starts, as the serving controller's.
|
||||||
func markServed() {
|
func markServed() {
|
||||||
if err := os.Setenv(servedVar, "1"); err != nil {
|
if err := os.Setenv(servedVar, "1"); err != nil {
|
||||||
|
|||||||
@@ -216,6 +216,10 @@ func run() error {
|
|||||||
func usage() {
|
func usage() {
|
||||||
fmt.Fprint(os.Stderr, `mesh-controller — the control plane
|
fmt.Fprint(os.Stderr, `mesh-controller — the control plane
|
||||||
|
|
||||||
|
On a node the operator types these as 'mesh-cli <command>' (in zsh, 'nox <command>'): asked
|
||||||
|
through the node's engine, and run as the controller's terminal only on the control-node
|
||||||
|
(novox/hq ADR 0272).
|
||||||
|
|
||||||
migrate bring each context's schema up to date
|
migrate bring each context's schema up to date
|
||||||
prepare the same, asked the way the mesh asks any module (ADR 0135)
|
prepare the same, asked the way the mesh asks any module (ADR 0135)
|
||||||
node add <name> [--adopted] create a node record; --adopted: the machine is in use
|
node add <name> [--adopted] create a node record; --adopted: the machine is in use
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The operator's command, `mesh-cli` (alias `nox`), answered here (novox/hq ADR 0272).
|
||||||
|
//
|
||||||
|
// mesh-cli asks the node-engine on its own machine; the engine reads the asking account from the kernel and asks
|
||||||
|
// this controller on its own node's subject. Here it is judged — the controller's terminal, an ordinary call, or
|
||||||
|
// nothing — and run as every verb's line is: a fresh process of this binary, with this process's environment.
|
||||||
|
//
|
||||||
|
// **The terminal** is a line from a node's operator account, on the control-node (§4). Phase 2 adds a node whose
|
||||||
|
// agents run under an account of their own, judged unable to become root (ADR 0266, not built yet); until then no
|
||||||
|
// other node is the terminal, because agents there run as its operator. **An ordinary call** is a line from that
|
||||||
|
// account elsewhere: it meets every refusal of the generic `command` verb and runs with `MESH_VERB=mesh-cli`, so a
|
||||||
|
// terminal-only change is refused with its reason. **Any other account is refused**, root included: those two
|
||||||
|
// accounts already reach the mesh's verbs through the mesh MCP server, and no other account gains anything here.
|
||||||
|
|
||||||
|
// cliVerb is what a line from mesh-cli that is not the terminal runs as: the verb its process names, so every
|
||||||
|
// terminal-only refusal applies and says it came through mesh-cli.
|
||||||
|
const cliVerb = "mesh-cli"
|
||||||
|
|
||||||
|
// cliServers are commands that serve until stopped. Run for mesh-cli they would hold a second server under this
|
||||||
|
// one until the call's bound killed it.
|
||||||
|
var cliServers = map[string]bool{"serve": true, "api": true, "board": true}
|
||||||
|
|
||||||
|
// cliVerdict is how a line is run, or why it is not.
|
||||||
|
type cliVerdict struct {
|
||||||
|
terminal bool
|
||||||
|
// why says why the line is or is not the terminal, in words the operator reads under the answer.
|
||||||
|
why string
|
||||||
|
// refused says why nothing runs.
|
||||||
|
refused string
|
||||||
|
}
|
||||||
|
|
||||||
|
// judgeCLI decides how a line from mesh-cli runs (ADR 0272 §4). nodes is every node the mesh knows; control is
|
||||||
|
// every node the controller's module is assigned to, which is exactly one in a mesh that is well.
|
||||||
|
func judgeCLI(node string, asked link.CLIAsked, nodes []inventory.Node, control []string) cliVerdict {
|
||||||
|
var record *inventory.Node
|
||||||
|
for i := range nodes {
|
||||||
|
if nodes[i].Name == node {
|
||||||
|
record = &nodes[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case record == nil:
|
||||||
|
return cliVerdict{refused: fmt.Sprintf("%s is not a node this mesh knows, so nothing ran", node)}
|
||||||
|
case asked.UID == 0 || asked.Account == "root":
|
||||||
|
return cliVerdict{refused: "mesh-cli answers the operator's own account, never root: run it as yourself, " +
|
||||||
|
"not under sudo. Nothing ran"}
|
||||||
|
case record.Account == "":
|
||||||
|
return cliVerdict{refused: fmt.Sprintf("the mesh does not know %s's operator account (`node account <node> <login>`), "+
|
||||||
|
"so no account there is answered. Nothing ran", node)}
|
||||||
|
case asked.Account != record.Account:
|
||||||
|
return cliVerdict{refused: fmt.Sprintf("mesh-cli answers %s's operator account (%s) only, and this line came "+
|
||||||
|
"from %s. Nothing ran", node, record.Account, asked.Account)}
|
||||||
|
}
|
||||||
|
if len(control) != 1 {
|
||||||
|
return cliVerdict{why: fmt.Sprintf("not the controller's terminal: the mesh names %d control-nodes, and the "+
|
||||||
|
"terminal is the one control-node's operator account", len(control))}
|
||||||
|
}
|
||||||
|
if control[0] == node {
|
||||||
|
return cliVerdict{terminal: true, why: fmt.Sprintf("the controller's terminal: %s on the control-node %s",
|
||||||
|
asked.Account, node)}
|
||||||
|
}
|
||||||
|
return cliVerdict{why: fmt.Sprintf("not the controller's terminal: agents on %s may run as %s, so a "+
|
||||||
|
"terminal-only change is made from the control-node %s (novox/hq ADR 0272)", node, asked.Account, control[0])}
|
||||||
|
}
|
||||||
|
|
||||||
|
// controlNodes is every node the controller's module is assigned to.
|
||||||
|
func controlNodes(ctx context.Context, inv *inventory.Inventory, nodes []inventory.Node) ([]string, error) {
|
||||||
|
var out []string
|
||||||
|
for _, n := range nodes {
|
||||||
|
modules, err := inv.Assigned(ctx, n.Name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if slices.Contains(modules, controllerModule) {
|
||||||
|
out = append(out, n.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// controllerModule is the module that runs the controller, and so marks the control-node.
|
||||||
|
const controllerModule = "mesh-controller"
|
||||||
|
|
||||||
|
// answerMeshCLI is the serving controller's answer to mesh-cli.
|
||||||
|
func answerMeshCLI(inv *inventory.Inventory) link.CLIHandler {
|
||||||
|
return func(ctx context.Context, node string, asked link.CLIAsked) link.CLIAnswer {
|
||||||
|
if handingOver.Load() {
|
||||||
|
return link.CLIRefusal("this controller is stopping and runs no new command; ask again in a moment. Nothing ran")
|
||||||
|
}
|
||||||
|
nodes, err := inv.Nodes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return link.CLIRefusal("the mesh's nodes cannot be read, so nothing ran: " + err.Error())
|
||||||
|
}
|
||||||
|
control, err := controlNodes(ctx, inv, nodes)
|
||||||
|
if err != nil {
|
||||||
|
return link.CLIRefusal("which node is the control-node cannot be read, so nothing ran: " + err.Error())
|
||||||
|
}
|
||||||
|
return runForMeshCLI(ctx, node, asked, judgeCLI(node, asked, nodes, control))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cliJournal says one line in the controller's journal (its standard output); a variable so a test can read it.
|
||||||
|
var cliJournal = func(line string) { fmt.Println(line) }
|
||||||
|
|
||||||
|
// runForMeshCLI runs a judged line and answers what it said. Every line is said in the journal first: its call,
|
||||||
|
// who asked on which node, its command word only, and how it runs (review of ADR 0272).
|
||||||
|
func runForMeshCLI(ctx context.Context, node string, asked link.CLIAsked, v cliVerdict) link.CLIAnswer {
|
||||||
|
how := "as an ordinary call"
|
||||||
|
switch {
|
||||||
|
case v.refused != "":
|
||||||
|
how = "refused: " + v.refused
|
||||||
|
case v.terminal:
|
||||||
|
how = "as the controller's terminal"
|
||||||
|
}
|
||||||
|
call := link.CallIDIn(ctx)
|
||||||
|
if call == "" {
|
||||||
|
call = "(no call)"
|
||||||
|
}
|
||||||
|
cliJournal(fmt.Sprintf("mesh-cli %s: %s on %s asked %q, %s", call, asked.Account, node, asked.Line[0], how))
|
||||||
|
if v.refused != "" {
|
||||||
|
return link.CLIRefusal(v.refused)
|
||||||
|
}
|
||||||
|
if cliServers[asked.Line[0]] {
|
||||||
|
return link.CLIAnswer{Exit: 1, Why: v.why, Refused: fmt.Sprintf("%s serves until stopped, and is not a "+
|
||||||
|
"command line mesh-cli runs. Nothing ran", asked.Line[0])}
|
||||||
|
}
|
||||||
|
verb, line := "", asked.Line
|
||||||
|
if !v.terminal {
|
||||||
|
composed, err := ordinaryLine(asked.Line)
|
||||||
|
if err != nil {
|
||||||
|
return link.CLIAnswer{Exit: 1, Why: v.why, Refused: err.Error()}
|
||||||
|
}
|
||||||
|
verb, line = cliVerb, composed
|
||||||
|
}
|
||||||
|
cmd := selfCommand(ctx, line)
|
||||||
|
cmd.Env = commandEnvironment(fmt.Sprintf("%s through mesh-cli on %s", asked.Account, node), verb)
|
||||||
|
// No standard input: a command that reads one gets nothing, and fails saying so (ADR 0272 §5).
|
||||||
|
cmd.Stdin = nil
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||||
|
err := cmd.Run()
|
||||||
|
answer := link.CLIAnswer{Stdout: stdout.Bytes(), Stderr: stderr.Bytes(), Terminal: v.terminal, Why: v.why}
|
||||||
|
var exit *exec.ExitError
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
case errors.As(err, &exit):
|
||||||
|
answer.Exit = exit.ExitCode()
|
||||||
|
if answer.Exit < 0 {
|
||||||
|
// Killed: by the call's bound, or by this controller stopping.
|
||||||
|
answer.Exit = 1
|
||||||
|
answer.Stderr = append(answer.Stderr, []byte(fmt.Sprintf("\nmesh-cli: the command was stopped (%v)\n",
|
||||||
|
exit))...)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
answer.Exit = 1
|
||||||
|
answer.Refused = fmt.Sprintf("could not run %s from this controller's own build: %v", strings.Join(asked.Line, " "), err)
|
||||||
|
}
|
||||||
|
return answer
|
||||||
|
}
|
||||||
|
|
||||||
|
// settingsForms is what an ordinary `settings` line may say: the settings verb's own forms.
|
||||||
|
const settingsForms = "settings set <module> <values> [--replace] [--node <node>], settings clear <module> " +
|
||||||
|
"[--node <node>], settings show <module> [--history] [--node <node>], or settings preferences [<module>] " +
|
||||||
|
"[--node <node>]"
|
||||||
|
|
||||||
|
// ordinaryLine is the command line an ordinary call runs (ADR 0272 §4): a `settings` line composed exactly as the
|
||||||
|
// settings verb composes its own, so its refusals — the terminal-only keys among them — are that verb's (review of
|
||||||
|
// ADR 0272); any other line as the generic `command` verb takes it, with its refusals.
|
||||||
|
func ordinaryLine(argv []string) ([]string, error) {
|
||||||
|
if len(argv) == 0 || argv[0] != "settings" {
|
||||||
|
// What the generic verb runs, and nothing it would refuse: its reading forms only, and never a command that
|
||||||
|
// is the terminal's alone (novox/hq ADR 0266) — the two checks argvFor makes of the `command` verb's line,
|
||||||
|
// made of the words as given rather than re-split from one string.
|
||||||
|
if err := refusedAsTheGenericCommand(argv); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := terminalOnly(argv); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return argv, nil
|
||||||
|
}
|
||||||
|
args := map[string]any{}
|
||||||
|
var words []string
|
||||||
|
rest := argv[1:]
|
||||||
|
for i := 0; i < len(rest); i++ {
|
||||||
|
w := rest[i]
|
||||||
|
switch {
|
||||||
|
case w == "--replace" || w == "-replace":
|
||||||
|
args["replace"] = "true"
|
||||||
|
case w == "--history" || w == "-history":
|
||||||
|
args["history"] = "true"
|
||||||
|
case w == "--node" || w == "-node":
|
||||||
|
if i+1 >= len(rest) {
|
||||||
|
return nil, fmt.Errorf("--node names no node; settings takes %s. Nothing ran", settingsForms)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
args["node"] = rest[i]
|
||||||
|
case strings.HasPrefix(w, "--node=") || strings.HasPrefix(w, "-node="):
|
||||||
|
_, args["node"], _ = strings.Cut(w, "=")
|
||||||
|
case strings.HasPrefix(w, "-"):
|
||||||
|
return nil, fmt.Errorf("settings takes no %s through mesh-cli outside the terminal; it takes %s. "+
|
||||||
|
"Nothing ran", w, settingsForms)
|
||||||
|
default:
|
||||||
|
words = append(words, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wrong := fmt.Errorf("through mesh-cli outside the terminal, settings takes the settings verb's forms: %s. "+
|
||||||
|
"Nothing ran", settingsForms)
|
||||||
|
if len(words) == 0 {
|
||||||
|
return nil, wrong
|
||||||
|
}
|
||||||
|
switch words[0] {
|
||||||
|
case "set":
|
||||||
|
if len(words) != 3 {
|
||||||
|
return nil, wrong
|
||||||
|
}
|
||||||
|
args["module"], args["values"] = words[1], words[2]
|
||||||
|
case "clear":
|
||||||
|
if len(words) != 2 {
|
||||||
|
return nil, wrong
|
||||||
|
}
|
||||||
|
args["module"], args["clear"] = words[1], "true"
|
||||||
|
case "show":
|
||||||
|
if len(words) != 2 {
|
||||||
|
return nil, wrong
|
||||||
|
}
|
||||||
|
args["module"] = words[1]
|
||||||
|
case "preferences":
|
||||||
|
if len(words) > 2 {
|
||||||
|
return nil, wrong
|
||||||
|
}
|
||||||
|
args["list"] = "preferences"
|
||||||
|
if len(words) == 2 {
|
||||||
|
args["module"] = words[1]
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, wrong
|
||||||
|
}
|
||||||
|
return argvFor("settings", args)
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
"github.com/novox/mesh-controller/internal/testbus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// echoEnvironment makes the test binary, run as a command line, say the verb and caller it was given (TestMain).
|
||||||
|
const echoEnvironment = "MESH_TEST_ECHO_ENVIRONMENT"
|
||||||
|
|
||||||
|
var cliNodes = []inventory.Node{
|
||||||
|
{Name: "control", Account: "operator"},
|
||||||
|
{Name: "laptop", Account: "operator"},
|
||||||
|
{Name: "unnamed"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func asked(account string, uid uint32, line ...string) link.CLIAsked {
|
||||||
|
return link.CLIAsked{Line: line, Account: account, UID: uid}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Who is the controller's terminal, and who is an ordinary call or refused (novox/hq ADR 0272 §4).
|
||||||
|
func TestMeshCLIIsTheTerminalOnlyForTheControlNodesOperator(t *testing.T) {
|
||||||
|
control := []string{"control"}
|
||||||
|
cases := []struct {
|
||||||
|
name, node string
|
||||||
|
asked link.CLIAsked
|
||||||
|
control []string
|
||||||
|
terminal bool
|
||||||
|
refused string
|
||||||
|
why string
|
||||||
|
}{
|
||||||
|
{"the control-node's operator", "control", asked("operator", 1000, "status"), control, true, "", "the controller's terminal"},
|
||||||
|
{"another node's operator", "laptop", asked("operator", 1000, "status"), control, false, "", "agents on laptop may run as operator"},
|
||||||
|
{"another account", "control", asked("agent", 1001, "status"), control, false, "operator account (operator) only", ""},
|
||||||
|
{"root", "control", asked("root", 0, "status"), control, false, "never root", ""},
|
||||||
|
{"a node with no operator account", "unnamed", asked("operator", 1000, "status"), control, false, "does not know unnamed's operator account", ""},
|
||||||
|
{"a node the mesh does not know", "elsewhere", asked("operator", 1000, "status"), control, false, "not a node this mesh knows", ""},
|
||||||
|
{"two control-nodes", "control", asked("operator", 1000, "status"), []string{"control", "laptop"}, false, "", "2 control-nodes"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
v := judgeCLI(c.node, c.asked, cliNodes, c.control)
|
||||||
|
if v.terminal != c.terminal {
|
||||||
|
t.Errorf("%s: terminal %v, want %v (%+v)", c.name, v.terminal, c.terminal, v)
|
||||||
|
}
|
||||||
|
if c.refused == "" && v.refused != "" || c.refused != "" && !strings.Contains(v.refused, c.refused) {
|
||||||
|
t.Errorf("%s: refused %q, want %q", c.name, v.refused, c.refused)
|
||||||
|
}
|
||||||
|
if c.why != "" && !strings.Contains(v.why, c.why) {
|
||||||
|
t.Errorf("%s: why %q, want %q", c.name, v.why, c.why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The terminal runs its line without MESH_VERB, even where this process carries one; an ordinary call names
|
||||||
|
// mesh-cli; both record who asked through mesh-cli.
|
||||||
|
func TestTheTerminalRunsWithoutAVerbAndAnOrdinaryCallNamesMeshCLI(t *testing.T) {
|
||||||
|
t.Setenv(echoEnvironment, "1")
|
||||||
|
t.Setenv("MESH_VERB", "leaked-from-the-serving-process")
|
||||||
|
// The serving controller marks itself (ADR 0266); its terminal line for mesh-cli is still the terminal's.
|
||||||
|
t.Setenv(servedVar, "1")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
a := runForMeshCLI(ctx, "control", asked("operator", 1000, "status"), cliVerdict{terminal: true, why: "the terminal"})
|
||||||
|
if a.Exit != 0 || a.Refused != "" || !a.Terminal {
|
||||||
|
t.Fatalf("the terminal's line did not run: %+v", a)
|
||||||
|
}
|
||||||
|
if got := string(a.Stdout); !strings.Contains(got, `verb=""`) || !strings.Contains(got, "operator through mesh-cli on control") ||
|
||||||
|
!strings.Contains(got, "terminal=true") {
|
||||||
|
t.Fatalf("the terminal's line ran with %s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
a = runForMeshCLI(ctx, "laptop", asked("operator", 1000, "status"), cliVerdict{why: "not the terminal"})
|
||||||
|
if a.Exit != 0 || a.Terminal || a.Why != "not the terminal" {
|
||||||
|
t.Fatalf("an ordinary line did not run as one: %+v", a)
|
||||||
|
}
|
||||||
|
if got := string(a.Stdout); !strings.Contains(got, `verb="mesh-cli"`) || !strings.Contains(got, "terminal=false") {
|
||||||
|
t.Fatalf("an ordinary line ran with %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ordinary call meets every refusal of the generic command verb, and nothing runs; a server is never run.
|
||||||
|
func TestAnOrdinaryCallMeetsTheCommandVerbsRefusals(t *testing.T) {
|
||||||
|
t.Setenv(echoEnvironment, "1")
|
||||||
|
ctx := context.Background()
|
||||||
|
ordinary := cliVerdict{why: "not the terminal"}
|
||||||
|
a := runForMeshCLI(ctx, "laptop", asked("operator", 1000, "cleanup", "delete", "x"), ordinary)
|
||||||
|
if a.Refused == "" || len(a.Stdout) != 0 || a.Exit != 1 || a.Why != "not the terminal" {
|
||||||
|
t.Fatalf("a repair without --why ran as an ordinary call: %+v", a)
|
||||||
|
}
|
||||||
|
a = runForMeshCLI(ctx, "laptop", asked("operator", 1000, "settings", "set", "claude-code", "{}"), ordinary)
|
||||||
|
if a.Refused != "" || !strings.Contains(string(a.Stdout), `verb="mesh-cli"`) {
|
||||||
|
t.Fatalf("an ordinary settings set did not run through the settings verb's path with MESH_VERB set: %+v", a)
|
||||||
|
}
|
||||||
|
for _, server := range []string{"serve", "api", "board"} {
|
||||||
|
a := runForMeshCLI(ctx, "control", asked("operator", 1000, server), cliVerdict{terminal: true})
|
||||||
|
if a.Refused == "" || len(a.Stdout) != 0 {
|
||||||
|
t.Fatalf("%s was run for mesh-cli: %+v", server, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a = runForMeshCLI(ctx, "control", asked("agent", 1001, "status"), cliVerdict{refused: "agent is not answered"})
|
||||||
|
if a.Refused != "agent is not answered" || len(a.Stdout) != 0 {
|
||||||
|
t.Fatalf("a refused line ran: %+v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Which node is the terminal does not follow a verb** (review of ADR 0272): the controller's module is assigned
|
||||||
|
// and unassigned at the terminal alone, so no caller of `assign` can move the terminal to a node of its choosing.
|
||||||
|
// Asked through the acts themselves, as the verbs ask them; refused before any store is touched (none is given).
|
||||||
|
func TestTheControllersModuleIsMovedAtTheTerminalAlone(t *testing.T) {
|
||||||
|
t.Setenv("MESH_VERB", "assign")
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := assignWith(ctx, nil, "laptop", assignOptions{}, "zsh", "mesh-controller"); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "terminal") {
|
||||||
|
t.Fatalf("assigning the controller through a verb was not refused: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := assign(ctx, nil, "laptop", "mesh-controller"); err == nil || !strings.Contains(err.Error(), "terminal") {
|
||||||
|
t.Fatalf("assigning the controller through a verb was not refused: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("MESH_VERB", "unassign")
|
||||||
|
if _, err := unassign(ctx, nil, "control", "mesh-controller"); err == nil || !strings.Contains(err.Error(), "terminal") {
|
||||||
|
t.Fatalf("unassigning the controller through a verb was not refused: %v", err)
|
||||||
|
}
|
||||||
|
// Another module through a verb, and the controller's at the terminal, are not refused for it.
|
||||||
|
if err := refusedMovingTheController([]string{"zsh"}); err != nil {
|
||||||
|
t.Fatalf("another module was refused: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("MESH_VERB", "")
|
||||||
|
if err := refusedMovingTheController([]string{"mesh-controller"}); err != nil {
|
||||||
|
t.Fatalf("the terminal was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ordinary `settings set|clear` goes down the settings verb's own path: composed as that verb composes it, and
|
||||||
|
// run with MESH_VERB set, so its refusals — the terminal-only keys among them — are the settings command's own,
|
||||||
|
// not a blanket refusal of the generic command (review of ADR 0272).
|
||||||
|
func TestAnOrdinarySettingsLineTakesTheSettingsVerbsPath(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
line []string
|
||||||
|
want string
|
||||||
|
err string
|
||||||
|
}{
|
||||||
|
{[]string{"settings", "set", "zsh", `{"execute":"withhold"}`, "--node", "laptop"}, `settings set zsh {"execute":"withhold"} --node laptop`, ""},
|
||||||
|
{[]string{"settings", "set", "zsh", "{}", "--replace"}, "settings set zsh {} --replace", ""},
|
||||||
|
{[]string{"settings", "clear", "zsh", "--node", "laptop"}, "settings clear zsh --node laptop", ""},
|
||||||
|
{[]string{"settings", "show", "zsh", "--history"}, "settings show zsh --history", ""},
|
||||||
|
{[]string{"settings", "preferences"}, "settings preferences", ""},
|
||||||
|
{[]string{"settings", "set", "zsh"}, "", "settings"},
|
||||||
|
{[]string{"settings", "set", "zsh", "{}", "--sideways"}, "", "--sideways"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
argv, err := ordinaryLine(c.line)
|
||||||
|
if c.err != "" {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), c.err) {
|
||||||
|
t.Errorf("%q: refused with %v, want %q", c.line, err, c.err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil || strings.Join(argv, " ") != c.want {
|
||||||
|
t.Errorf("%q: composed %q (%v), want %q", c.line, argv, err, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Anything else still meets the generic command verb's refusals.
|
||||||
|
if _, err := ordinaryLine([]string{"retire", "delete", "x"}); err == nil {
|
||||||
|
t.Error("a repair composed as an ordinary line")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every line is said in the controller's journal, with its call, who asked where and how it ran — its command word
|
||||||
|
// only, never the rest of the line (review of ADR 0272).
|
||||||
|
func TestEveryMeshCLILineIsSaidInTheJournal(t *testing.T) {
|
||||||
|
t.Setenv(echoEnvironment, "1")
|
||||||
|
var said []string
|
||||||
|
was := cliJournal
|
||||||
|
cliJournal = func(line string) { said = append(said, line) }
|
||||||
|
t.Cleanup(func() { cliJournal = was })
|
||||||
|
ctx := link.WithCallID(context.Background(), "call-1")
|
||||||
|
runForMeshCLI(ctx, "control", asked("operator", 1000, "settings", "set", "x", `{"password":"s3cret"}`),
|
||||||
|
cliVerdict{terminal: true, why: "the terminal"})
|
||||||
|
runForMeshCLI(ctx, "control", asked("agent", 1001, "status"), cliVerdict{refused: "agent is not answered"})
|
||||||
|
all := strings.Join(said, "\n")
|
||||||
|
if len(said) != 2 || !strings.Contains(all, "call-1") || !strings.Contains(all, "operator on control") ||
|
||||||
|
!strings.Contains(all, "as the controller's terminal") || !strings.Contains(all, "refused") {
|
||||||
|
t.Fatalf("the journal said %q", said)
|
||||||
|
}
|
||||||
|
if strings.Contains(all, "s3cret") {
|
||||||
|
t.Fatalf("the journal carries the line's values: %q", said)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **An ordinary line runs only what the generic command verb would** (review of ADR 0272, ADR 0266): its reading
|
||||||
|
// forms, and never a command that is the terminal's alone. Each of these, from another node's operator, is refused
|
||||||
|
// and runs nothing.
|
||||||
|
func TestAnOrdinaryLineRunsNothingTheCommandVerbWouldRefuse(t *testing.T) {
|
||||||
|
t.Setenv(echoEnvironment, "1")
|
||||||
|
for _, line := range [][]string{
|
||||||
|
{"node", "account", "control", "x"},
|
||||||
|
{"node", "agent-account", "control", "x", "--clear"},
|
||||||
|
{"token", "issue", "laptop"},
|
||||||
|
{"secret", "export", "x"},
|
||||||
|
{"operator", "key", "set", "x"},
|
||||||
|
{"assign", "laptop", "zsh"},
|
||||||
|
} {
|
||||||
|
if _, err := ordinaryLine(line); err == nil {
|
||||||
|
t.Errorf("%q composed as an ordinary line", line)
|
||||||
|
}
|
||||||
|
a := runForMeshCLI(context.Background(), "laptop", asked("operator", 1000, line...), cliVerdict{why: "not the terminal"})
|
||||||
|
if a.Refused == "" || len(a.Stdout) != 0 {
|
||||||
|
t.Errorf("%q ran as an ordinary line: %+v", line, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if argv, err := ordinaryLine([]string{"status"}); err != nil || argv[0] != "status" {
|
||||||
|
t.Fatalf("a read was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **`calls` never shows a mesh-cli line's answer** (review of ADR 0272): the verb's own answer is read for a line
|
||||||
|
// served over a bus, and neither the answer's text nor the base64 JSON writes its bytes in is there.
|
||||||
|
func TestTheCallsVerbNeverShowsAMeshCLILinesAnswer(t *testing.T) {
|
||||||
|
conn, err := nats.Connect(testbus.URL(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
stop, err := link.OverNATS{Conn: conn}.ServeCLI(func(context.Context, string, link.CLIAsked) link.CLIAnswer {
|
||||||
|
return link.CLIAnswer{Stdout: []byte("s3cret-join")}
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer stop()
|
||||||
|
body, _ := json.Marshal(link.CLIAsked{Line: []string{"token", "issue", "x"}, Account: "operator"})
|
||||||
|
msg, err := conn.Request(link.CLISubject("control"), body, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(msg.Data), base64.StdEncoding.EncodeToString([]byte("s3cret-join"))) {
|
||||||
|
t.Fatalf("the asker was not given its answer: %s", msg.Data)
|
||||||
|
}
|
||||||
|
recent, _ := link.Calls.Recent()
|
||||||
|
var id string
|
||||||
|
for _, c := range recent {
|
||||||
|
if c.Seat == link.CLISeat {
|
||||||
|
id = c.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shown, err := callsAnswer(link.Calls, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
said, _ := json.Marshal(shown)
|
||||||
|
if strings.Contains(string(said), "s3cret-join") ||
|
||||||
|
strings.Contains(string(said), base64.StdEncoding.EncodeToString([]byte("s3cret-join"))) {
|
||||||
|
t.Fatalf("calls showed a mesh-cli line's answer: %s", said)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1107,7 +1107,7 @@ func refuseTerminalSettingsThroughAVerb(ctx context.Context, inv *inventory.Inve
|
|||||||
}
|
}
|
||||||
// A trusted mergeable file takes any key, so its module's whole layer is the terminal's (novox/hq issue 340).
|
// A trusted mergeable file takes any key, so its module's whole layer is the terminal's (novox/hq issue 340).
|
||||||
if files := catalogue.TrustedMergeable(shelf[module]); len(files) > 0 && !sameLayer(before, after) {
|
if files := catalogue.TrustedMergeable(shelf[module]); len(files) > 0 && !sameLayer(before, after) {
|
||||||
return fmt.Errorf("the settings of %s on %s are set at the controller's terminal only, never through a verb (this "+
|
return fmt.Errorf("the settings of %s on %s are set at the controller's terminal only (`mesh-cli` on the control-node), never through a verb (this "+
|
||||||
"line came through %q): %s merges whatever key a layer sets into a file root or a consumer trusts, so "+
|
"line came through %q): %s merges whatever key a layer sets into a file root or a consumer trusts, so "+
|
||||||
"any key could point the module at a listener of the caller's, and whoever may call a verb includes "+
|
"any key could point the module at a listener of the caller's, and whoever may call a verb includes "+
|
||||||
"agents (novox/hq issue 340; a file nothing trusts says \"trusted\": false). Nothing was changed",
|
"agents (novox/hq issue 340; a file nothing trusts says \"trusted\": false). Nothing was changed",
|
||||||
@@ -1119,7 +1119,7 @@ func refuseTerminalSettingsThroughAVerb(ctx context.Context, inv *inventory.Inve
|
|||||||
if string(was) == string(now) {
|
if string(was) == string(now) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return fmt.Errorf("%s of %s on %s is set at the controller's terminal only, never through a verb (this "+
|
return fmt.Errorf("%s of %s on %s is set at the controller's terminal only (`mesh-cli` on the control-node), never through a verb (this "+
|
||||||
"line came through %q): it says where root creates and owns a module's directories, which of "+
|
"line came through %q): it says where root creates and owns a module's directories, which of "+
|
||||||
"the machine's paths are mounted into its container, what the mesh's consumers trust, or what a file "+
|
"the machine's paths are mounted into its container, what the mesh's consumers trust, or what a file "+
|
||||||
"root or a person's session obeys takes, and whoever may call a verb includes agents (novox/hq issue 339; "+
|
"root or a person's session obeys takes, and whoever may call a verb includes agents (novox/hq issue 339; "+
|
||||||
|
|||||||
@@ -266,6 +266,12 @@ func serve(ctx context.Context) (err error) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer stopServing()
|
defer stopServing()
|
||||||
|
// And the operator's command on every node, asked through each node's engine (novox/hq ADR 0272).
|
||||||
|
stopCLI, err := bus.ServeCLI(answerMeshCLI(open.inventory), log.New(os.Stdout, "", log.LstdFlags))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stopCLI()
|
||||||
// And says so on the bus (novox/hq ADR 0197): what it serves, as the NATS services protocol asks.
|
// And says so on the bus (novox/hq ADR 0197): what it serves, as the NATS services protocol asks.
|
||||||
stopAnnouncing, err := bus.Announce(seatAnnouncement(handlers), log.New(os.Stdout, "", log.LstdFlags))
|
stopAnnouncing, err := bus.Announce(seatAnnouncement(handlers), log.New(os.Stdout, "", log.LstdFlags))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ import (
|
|||||||
// shape fails the suite, naming its source. The keeper would say such a summary in words at run time;
|
// shape fails the suite, naming its source. The keeper would say such a summary in words at run time;
|
||||||
// this is where the producer is made to say it rightly in the first place.
|
// this is where the producer is made to say it rightly in the first place.
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
|
// The process a mesh-cli test runs as a command line: it says the verb and the caller it was given, and
|
||||||
|
// ends (meshcli_test.go).
|
||||||
|
if os.Getenv(echoEnvironment) != "" {
|
||||||
|
fmt.Printf("verb=%q caller=%q terminal=%v\n", os.Getenv("MESH_VERB"), os.Getenv("MESH_CALLER"), startedAtTheTerminal())
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
conditions.Unsayable = func(o conditions.Observation, field string, r outward.Refusal) {
|
conditions.Unsayable = func(o conditions.Observation, field string, r outward.Refusal) {
|
||||||
unsaid.note(o, field, r)
|
unsaid.note(o, field, r)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,6 +229,35 @@ func quoteAll(xs []string) string {
|
|||||||
return strings.Join(q, ", ")
|
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
|
// 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.
|
// 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) {
|
func (a *verbArguments) commandLine() ([]string, error) {
|
||||||
@@ -245,28 +274,9 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(argv) == 0 {
|
if err := refusedAsTheGenericCommand(argv); err != nil {
|
||||||
return nil, 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 nil, &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 nil, err
|
return nil, 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 nil, 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 argv, nil
|
return argv, nil
|
||||||
case "tools":
|
case "tools":
|
||||||
return nil, errors.New("tools is answered from the records, not by a command")
|
return nil, errors.New("tools is answered from the records, not by a command")
|
||||||
@@ -919,6 +929,31 @@ func isWhyFlag(word string) bool {
|
|||||||
return word == "--why" || word == "-why" || strings.HasPrefix(word, "--why=") || strings.HasPrefix(word, "-why=")
|
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.
|
// 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**
|
// **This binary is the image this process runs, never the file at the path it started from**
|
||||||
@@ -932,21 +967,17 @@ func runVerb(ctx context.Context, argv []string) (verbAnswer, error) {
|
|||||||
return verbAnswer{}, fmt.Errorf("%w: this controller is stopping and runs no new command", link.ErrHandingOver)
|
return verbAnswer{}, fmt.Errorf("%w: this controller is stopping and runs no new command", link.ErrHandingOver)
|
||||||
}
|
}
|
||||||
cmd := selfCommand(ctx, argv)
|
cmd := selfCommand(ctx, 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()
|
|
||||||
// And who asked, so an act it does by hand is recorded as theirs (novox/hq to-be 45 §7).
|
// And who asked, so an act it does by hand is recorded as theirs (novox/hq to-be 45 §7).
|
||||||
caller := link.CallerIn(ctx)
|
caller := link.CallerIn(ctx)
|
||||||
if caller == "" {
|
if caller == "" {
|
||||||
caller = "a seat call whose caller the bus did not name"
|
caller = "a seat call whose caller the bus did not name"
|
||||||
}
|
}
|
||||||
cmd.Env = append(cmd.Env, link.CallerVar+"="+caller+", through the "+catalogue.ControllerSeatName+" seat")
|
|
||||||
// And which verb, so the connection it dials says so in the bus's list (novox/hq issue 327).
|
// And which verb, so the connection it dials says so in the bus's list (novox/hq issue 327).
|
||||||
verb, _ := ctx.Value(verbKey{}).(string)
|
verb, _ := ctx.Value(verbKey{}).(string)
|
||||||
if verb == "" {
|
if verb == "" {
|
||||||
verb = argv[0]
|
verb = argv[0]
|
||||||
}
|
}
|
||||||
cmd.Env = append(cmd.Env, verbVar+"="+verb)
|
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
|
// 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`
|
// 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
|
// prints its warnings beside the document, and a JSON parsed from the two together parsed
|
||||||
@@ -1168,7 +1199,7 @@ func answersFirst(argv []string) bool {
|
|||||||
// the reason said beside it.
|
// the reason said beside it.
|
||||||
func callsAnswer(log *link.CallLog, id string) (any, error) {
|
func callsAnswer(log *link.CallLog, id string) (any, error) {
|
||||||
if id != "" {
|
if id != "" {
|
||||||
c, ok, err := log.Get(id)
|
c, ok, err := log.Shown(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("call %s is not in this controller's memory, and the calls kept on the "+
|
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)
|
"bus could not be read: %w", id, err)
|
||||||
|
|||||||
@@ -87,8 +87,10 @@ var WritersTable = []WriterRow{
|
|||||||
{State: "a machine's applied state and its report", Writer: "the node-engine's apply queue",
|
{State: "a machine's applied state and its report", Writer: "the node-engine's apply queue",
|
||||||
KeptIn: "the machine; the report on the bus", Others: "the reconcile and a delivery enqueue, never apply",
|
KeptIn: "the machine; the report on the bus", Others: "the reconcile and a delivery enqueue, never apply",
|
||||||
// And its health statement between reports (novox/hq ADR 0240): the same writer stating the same
|
// And its health statement between reports (novox/hq ADR 0240): the same writer stating the same
|
||||||
// machine, inside the grant it already had (`mesh.control.<its own>.>`).
|
// machine, inside the grant it already had (`mesh.control.<its own>.>`). And a line mesh-cli was given
|
||||||
Subjects: []string{"mesh.control.*.report", "mesh.control.*.health"}, Writes: ownMachine},
|
// on the machine (novox/hq ADR 0272): the node is what the controller judges the terminal by, so that
|
||||||
|
// subject is this machine's engine's alone.
|
||||||
|
Subjects: []string{"mesh.control.*.report", "mesh.control.*.health", "mesh.control.*.cli"}, Writes: ownMachine},
|
||||||
{State: "the controller lease", Writer: "the controller instance holding it", KeptIn: "key-value " + LeaseBucket,
|
{State: "the controller lease", Writer: "the controller instance holding it", KeptIn: "key-value " + LeaseBucket,
|
||||||
Others: "a candidate waits", Subjects: kvOf(LeaseBucket), Writes: isController},
|
Others: "a candidate waits", Subjects: kvOf(LeaseBucket), Writes: isController},
|
||||||
{State: "plans and their tiers", Writer: "controller (lease holder), compare-and-set on the plan's revision",
|
{State: "plans and their tiers", Writer: "controller (lease holder), compare-and-set on the plan's revision",
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ func TestASecondWriterIsRefusedAtComposition(t *testing.T) {
|
|||||||
[]string{"mesh.control.>"}, "a machine's applied state and its report"},
|
[]string{"mesh.control.>"}, "a machine's applied state and its report"},
|
||||||
{"a machine publishing another's report", Principal{Kind: KindNode, Node: "one"},
|
{"a machine publishing another's report", Principal{Kind: KindNode, Node: "one"},
|
||||||
[]string{"mesh.control.two.report"}, "a machine's applied state and its report"},
|
[]string{"mesh.control.two.report"}, "a machine's applied state and its report"},
|
||||||
|
{"a machine asking the controller as another (mesh-cli, ADR 0272)", Principal{Kind: KindNode, Node: "one"},
|
||||||
|
[]string{"mesh.control.two.cli"}, "a machine's applied state and its report"},
|
||||||
|
{"a module asking the controller as a machine (mesh-cli, ADR 0272)", Principal{Kind: KindModule, Module: "shop"},
|
||||||
|
[]string{"mesh.control.one.cli"}, "a machine's applied state and its report"},
|
||||||
{"a machine publishing every machine's", Principal{Kind: KindNode, Node: "one"},
|
{"a machine publishing every machine's", Principal{Kind: KindNode, Node: "one"},
|
||||||
[]string{"mesh.control.*.>"}, "a machine's applied state and its report"},
|
[]string{"mesh.control.*.>"}, "a machine's applied state and its report"},
|
||||||
{"a module writing the lease", Principal{Kind: KindModule, Module: "shop"},
|
{"a module writing the lease", Principal{Kind: KindModule, Module: "shop"},
|
||||||
@@ -115,6 +119,7 @@ func TestASecondWriterIsRefusedAtComposition(t *testing.T) {
|
|||||||
publish []string
|
publish []string
|
||||||
}{
|
}{
|
||||||
{Principal{Kind: KindNode, Node: "one"}, []string{"mesh.control.one.>"}},
|
{Principal{Kind: KindNode, Node: "one"}, []string{"mesh.control.one.>"}},
|
||||||
|
{Principal{Kind: KindNode, Node: "one"}, []string{"mesh.control.one.cli"}},
|
||||||
{Principal{Kind: KindController}, []string{"mesh.node.>", "$JS.API.>", "$KV.mesh-controller_lease.>"}},
|
{Principal{Kind: KindController}, []string{"mesh.node.>", "$JS.API.>", "$KV.mesh-controller_lease.>"}},
|
||||||
{Principal{Kind: KindModule, Module: "gitea"}, []string{"mesh.mod.gitea.event.pull.merged"}},
|
{Principal{Kind: KindModule, Module: "gitea"}, []string{"mesh.mod.gitea.event.pull.merged"}},
|
||||||
{Principal{Kind: KindModule, Module: "shop"}, []string{"$JS.API.CONSUMER.CREATE.KV_shop_carts.>"}},
|
{Principal{Kind: KindModule, Module: "shop"}, []string{"$JS.API.CONSUMER.CREATE.KV_shop_carts.>"}},
|
||||||
|
|||||||
@@ -324,6 +324,15 @@ func (l *CallLog) finish(c *Call, answer []byte, failed, answeredAlready bool) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
onTheBus := *c
|
onTheBus := *c
|
||||||
|
if c.Seat == CLISeat {
|
||||||
|
// **A mesh-cli line's answer is its asker's alone** (ADR 0272): what a command at the controller's terminal
|
||||||
|
// printed — a token, a secret's reference — and `calls` answers anyone who may call the seat. Kept in this
|
||||||
|
// process's memory for the asker to follow, and never on the bus.
|
||||||
|
onTheBus.Answer, _ = json.Marshal(map[string]any{"not kept": "a mesh-cli line's answer, its asker's alone: " +
|
||||||
|
"kept in the memory of the controller that ran it, for the asker to follow"})
|
||||||
|
l.keep(onTheBus)
|
||||||
|
return
|
||||||
|
}
|
||||||
if len(onTheBus.Answer) > keptOnTheBusAtMost {
|
if len(onTheBus.Answer) > keptOnTheBusAtMost {
|
||||||
// A record on the bus is one message too, and one larger than the bus carries is refused whole —
|
// A record on the bus is one message too, and one larger than the bus carries is refused whole —
|
||||||
// the call's state with it (novox/hq issue 314). The answer stays in this process's memory, and
|
// the call's state with it (novox/hq issue 314). The answer stays in this process's memory, and
|
||||||
@@ -391,6 +400,29 @@ func (l *CallLog) Recent() ([]Call, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inMemory is one call this process served and still holds, whole.
|
||||||
|
func (l *CallLog) inMemory(id string) (Call, bool) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
for _, c := range l.calls {
|
||||||
|
if c.ID == id {
|
||||||
|
return *c, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Call{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shown is one kept call as `calls` shows it: whole, but for a mesh-cli line's answer, which is its asker's alone
|
||||||
|
// and followed by it (ADR 0272) — `calls` answers anyone who may call the seat.
|
||||||
|
func (l *CallLog) Shown(id string) (Call, bool, error) {
|
||||||
|
c, found, err := l.Get(id)
|
||||||
|
if found && c.Seat == CLISeat {
|
||||||
|
c.Answer, _ = json.Marshal(map[string]any{"not shown": "a mesh-cli line's answer is its asker's alone: " +
|
||||||
|
"mesh-cli follows it"})
|
||||||
|
}
|
||||||
|
return c, found, err
|
||||||
|
}
|
||||||
|
|
||||||
// Get is one kept call: from memory, or from the bus when this process did not serve it.
|
// Get is one kept call: from memory, or from the bus when this process did not serve it.
|
||||||
func (l *CallLog) Get(id string) (Call, bool, error) {
|
func (l *CallLog) Get(id string) (Call, bool, error) {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
@@ -431,6 +463,11 @@ func kept(args json.RawMessage) json.RawMessage {
|
|||||||
switch {
|
switch {
|
||||||
case k == "values" || k == "secret":
|
case k == "values" || k == "secret":
|
||||||
out[k] = "(given, not kept)"
|
out[k] = "(given, not kept)"
|
||||||
|
case k == "line":
|
||||||
|
// A mesh-cli line (ADR 0272): its command word, never the rest, which may carry settings.
|
||||||
|
if words, ok := v.([]any); ok && len(words) > 0 {
|
||||||
|
out[k] = []any{words[0], "(the rest given, not kept)"}
|
||||||
|
}
|
||||||
case isString && len(s) <= 120:
|
case isString && len(s) <= 120:
|
||||||
out[k] = s
|
out[k] = s
|
||||||
case isString:
|
case isString:
|
||||||
@@ -513,6 +550,19 @@ var watched struct {
|
|||||||
conns map[*nats.Conn]bool
|
conns map[*nats.Conn]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type callIDKey struct{}
|
||||||
|
|
||||||
|
// WithCallID is ctx carrying the call it serves; CallIDIn reads it back, empty outside one.
|
||||||
|
func WithCallID(ctx context.Context, id string) context.Context {
|
||||||
|
return context.WithValue(ctx, callIDKey{}, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallIDIn is the call ctx serves, or empty.
|
||||||
|
func CallIDIn(ctx context.Context) string {
|
||||||
|
id, _ := ctx.Value(callIDKey{}).(string)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
// answerNow is how a handler asks for its caller to be answered before it goes on (Acknowledge).
|
// answerNow is how a handler asks for its caller to be answered before it goes on (Acknowledge).
|
||||||
type answerNowKey struct{}
|
type answerNowKey struct{}
|
||||||
|
|
||||||
@@ -565,6 +615,8 @@ func (l *CallLog) serveCallWithin(seat, verb string, args json.RawMessage, reply
|
|||||||
c := l.begin(seat, verb, kept(args), reply)
|
c := l.begin(seat, verb, kept(args), reply)
|
||||||
// Who asked travels with the call, so an act it does by hand says so (novox/hq to-be 45 §7).
|
// Who asked travels with the call, so an act it does by hand says so (novox/hq to-be 45 §7).
|
||||||
ctx = context.WithValue(ctx, callerKey{}, c.Caller)
|
ctx = context.WithValue(ctx, callerKey{}, c.Caller)
|
||||||
|
// And which call it is, for what the handler says of it in the journal.
|
||||||
|
ctx = WithCallID(ctx, c.ID)
|
||||||
acknowledged := make(chan struct{})
|
acknowledged := make(chan struct{})
|
||||||
var once sync.Once
|
var once sync.Once
|
||||||
ctx = context.WithValue(ctx, answerNowKey{}, func() { once.Do(func() { close(acknowledged) }) })
|
ctx = context.WithValue(ctx, answerNowKey{}, func() { once.Do(func() { close(acknowledged) }) })
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package link
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mesh-cli asks the controller through the node-engine of the machine it runs on (novox/hq ADR 0272 §3). The engine
|
||||||
|
// reads the asking account from the kernel and asks on its own node's subject, which only that node's engine may
|
||||||
|
// publish (its grant is `mesh.control.<node>.>`): so the node is a fact the bus server enforces, and the account a
|
||||||
|
// fact the kernel gave. The engine's side holds the same field names (mesh-host internal/meshcli, a test on each
|
||||||
|
// side), as for its health statement.
|
||||||
|
|
||||||
|
// CLISubjects is every node's mesh-cli subject, which the serving controller answers.
|
||||||
|
const CLISubjects = "mesh.control.*.cli"
|
||||||
|
|
||||||
|
// CLISubject is one node's.
|
||||||
|
func CLISubject(node string) string { return "mesh.control." + node + ".cli" }
|
||||||
|
|
||||||
|
// CLISeat is what a mesh-cli line is recorded under in the calls record, beside the seats' verbs: its verb there is
|
||||||
|
// the node it was asked on.
|
||||||
|
const CLISeat = "mesh-cli"
|
||||||
|
|
||||||
|
// CLIAtOnce bounds the mesh-cli lines running at once, from every node together. A person types one at a time; one
|
||||||
|
// over the bound is answered busy at once, and nothing runs.
|
||||||
|
var CLIAtOnce = 8
|
||||||
|
|
||||||
|
// CLIAsked is a line mesh-cli was given on a node, and the account the node-engine says is asking — or, with
|
||||||
|
// Follow, the call a line runs as, asked again by the same account on the same node until it ends.
|
||||||
|
type CLIAsked struct {
|
||||||
|
Line []string `json:"line,omitempty"`
|
||||||
|
Account string `json:"account"`
|
||||||
|
UID uint32 `json:"uid"`
|
||||||
|
Follow string `json:"follow,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLIAnswer is what the controller answers, as the `result` of a call's answer: what the command printed, how it
|
||||||
|
// exited, whether it ran as the controller's terminal and why, or why nothing ran. A line still running is answered
|
||||||
|
// as every call is (`running`, `call`), and followed.
|
||||||
|
type CLIAnswer struct {
|
||||||
|
Stdout []byte `json:"stdout,omitempty"`
|
||||||
|
Stderr []byte `json:"stderr,omitempty"`
|
||||||
|
Exit int `json:"exit"`
|
||||||
|
Terminal bool `json:"terminal"`
|
||||||
|
Why string `json:"why,omitempty"`
|
||||||
|
Refused string `json:"refused,omitempty"`
|
||||||
|
Cut bool `json:"cut,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLIRefusal is an answer saying nothing ran, and why.
|
||||||
|
func CLIRefusal(why string) CLIAnswer { return CLIAnswer{Exit: 1, Refused: why} }
|
||||||
|
|
||||||
|
// CLINode is the node a mesh-cli subject names, or false for any other subject.
|
||||||
|
func CLINode(subject string) (string, bool) {
|
||||||
|
rest, ok := strings.CutPrefix(subject, "mesh.control.")
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
node, tail, ok := strings.Cut(rest, ".")
|
||||||
|
if !ok || tail != "cli" || node == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return node, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLIHandler answers one line from one node.
|
||||||
|
type CLIHandler func(ctx context.Context, node string, asked CLIAsked) CLIAnswer
|
||||||
|
|
||||||
|
// ServeCLI answers mesh-cli for every node until stopped: one queue group, so of two controllers during a handover
|
||||||
|
// one answers.
|
||||||
|
//
|
||||||
|
// **Every line is a call** (review of ADR 0272): run, recorded and answered as a seat's verb is (calls.go) — its
|
||||||
|
// caller answered within AnswerWithin that it is still running, with its call, and the line's answer kept for
|
||||||
|
// the asker to follow. The bus permits an answer for a minute only (broker.ResponseTTL); a line answered when it
|
||||||
|
// ends lost every answer after that, and mesh-cli said nothing ran of a line that had.
|
||||||
|
func (b OverNATS) ServeCLI(handle CLIHandler, logger *log.Logger) (func(), error) {
|
||||||
|
return b.serveCLI(Calls, CLIAtOnce, handle, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b OverNATS) serveCLI(calls *CallLog, atOnce int, handle CLIHandler, logger *log.Logger) (func(), error) {
|
||||||
|
done := make(chan struct{})
|
||||||
|
slots := make(chan struct{}, atOnce)
|
||||||
|
bind := func() (*nats.Subscription, error) {
|
||||||
|
return b.Conn.QueueSubscribe(CLISubjects, "mesh-cli", func(msg *nats.Msg) {
|
||||||
|
go b.answerCLI(msg, calls, slots, handle, logger)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sub, err := bind()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("serving %s: %w", CLISubjects, err)
|
||||||
|
}
|
||||||
|
go keepBound(sub, bind, CLISubjects, done, logger)
|
||||||
|
return func() {
|
||||||
|
close(done)
|
||||||
|
_ = sub.Unsubscribe()
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cliEnvelope is an answer as every call's is: its result.
|
||||||
|
func cliEnvelope(a CLIAnswer) []byte {
|
||||||
|
body, _ := json.Marshal(map[string]any{"result": a})
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b OverNATS) answerCLI(msg *nats.Msg, calls *CallLog, slots chan struct{}, handle CLIHandler, logger *log.Logger) {
|
||||||
|
if msg.Reply == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond := func(body []byte) {
|
||||||
|
if err := msg.Respond(body); err != nil && logger != nil {
|
||||||
|
logger.Printf("mesh-cli on %s: the answer could not be sent: %v", msg.Subject, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node, ok := CLINode(msg.Subject)
|
||||||
|
var asked CLIAsked
|
||||||
|
switch {
|
||||||
|
case !ok:
|
||||||
|
respond(cliEnvelope(CLIRefusal("not a mesh-cli subject: " + msg.Subject)))
|
||||||
|
return
|
||||||
|
case json.Unmarshal(msg.Data, &asked) != nil:
|
||||||
|
respond(cliEnvelope(CLIRefusal("the node-engine's request could not be read, so nothing ran")))
|
||||||
|
return
|
||||||
|
case asked.Follow != "":
|
||||||
|
respond(calls.followCLI(asked.Follow, node, asked.Account))
|
||||||
|
return
|
||||||
|
case len(asked.Line) == 0:
|
||||||
|
respond(cliEnvelope(CLIRefusal("the request names no command, so nothing ran")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case slots <- struct{}{}:
|
||||||
|
defer func() { <-slots }()
|
||||||
|
default:
|
||||||
|
respond(cliEnvelope(CLIRefusal(fmt.Sprintf("busy: %d lines from mesh-cli are running already; ask again "+
|
||||||
|
"when one has ended. Nothing ran", cap(slots)))))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := b.Conn.MaxPayload()
|
||||||
|
calls.serveCallWithin(CLISeat, node, msg.Data, msg.Reply, func(ctx context.Context, _ json.RawMessage) (any, error) {
|
||||||
|
return fitCLI(handle(ctx, node, asked), limit-4096), nil
|
||||||
|
}, msg.Respond, limit, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// followCLI answers the asker of a line what came of it: running still, its answer, or why that is not known. Only
|
||||||
|
// the account that asked it, on the node it was asked on: the answer is what the command printed, and `calls`
|
||||||
|
// keeps it from everyone else.
|
||||||
|
func (l *CallLog) followCLI(id, node, account string) []byte {
|
||||||
|
noSuch := func() []byte {
|
||||||
|
body, _ := json.Marshal(map[string]any{"error": fmt.Sprintf("no mesh-cli call %s was asked by %s on %s", id,
|
||||||
|
account, node)})
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
c, inMemory := l.inMemory(id)
|
||||||
|
if !inMemory {
|
||||||
|
kept, found, err := l.Get(id)
|
||||||
|
if err != nil || !found {
|
||||||
|
return noSuch()
|
||||||
|
}
|
||||||
|
c = kept
|
||||||
|
}
|
||||||
|
var args CLIAsked
|
||||||
|
_ = json.Unmarshal(c.Args, &args)
|
||||||
|
if c.Seat != CLISeat || c.Verb != node || args.Account != account {
|
||||||
|
return noSuch()
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case c.State == CallRunning:
|
||||||
|
return running(&c, AnswerWithin, false, l.Follow)
|
||||||
|
case c.State == CallAbandoned:
|
||||||
|
body, _ := json.Marshal(map[string]any{"error": fmt.Sprintf("call %s was running under a controller that "+
|
||||||
|
"stopped before it finished: it may have done part of what it was asked, and nothing will finish it", id)})
|
||||||
|
return body
|
||||||
|
case !inMemory:
|
||||||
|
body, _ := json.Marshal(map[string]any{"error": fmt.Sprintf("call %s finished (%s), and its answer was kept "+
|
||||||
|
"only in the memory of the controller that ran it, which has stopped; whether it took effect, the "+
|
||||||
|
"mesh says (status, the hand-act log)", id, c.State)})
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
return c.Answer
|
||||||
|
}
|
||||||
|
|
||||||
|
// fitCLI is an answer whose streams fit in limit bytes once written: what the command printed is cut, standard
|
||||||
|
// output first, and the cut is said (ADR 0272 §5) — never silently short.
|
||||||
|
func fitCLI(a CLIAnswer, limit int64) CLIAnswer {
|
||||||
|
body, _ := json.Marshal(a)
|
||||||
|
if limit <= 0 || int64(len(body)) <= limit {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
a.Cut = true
|
||||||
|
// Room for the rest of the answer and JSON's base64 of the streams (4 bytes for every 3).
|
||||||
|
room := (limit - 4096) * 3 / 4
|
||||||
|
if room < 0 {
|
||||||
|
room = 0
|
||||||
|
}
|
||||||
|
if int64(len(a.Stderr)) > room/2 {
|
||||||
|
a.Stderr = a.Stderr[:room/2]
|
||||||
|
}
|
||||||
|
if left := room - int64(len(a.Stderr)); int64(len(a.Stdout)) > left {
|
||||||
|
if left < 0 {
|
||||||
|
left = 0
|
||||||
|
}
|
||||||
|
a.Stdout = a.Stdout[:left]
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// FitCLIAnswer is the answer as one bus message of at most limit bytes, written.
|
||||||
|
func FitCLIAnswer(a CLIAnswer, limit int64) []byte {
|
||||||
|
body, _ := json.Marshal(fitCLI(a, limit))
|
||||||
|
return body
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package link
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/testbus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The node-engine's request and the answer it hands mesh-cli hold these field names; the engine's side holds the
|
||||||
|
// same list (mesh-host internal/meshcli, TestTheRequestToTheControllerKeepsItsFieldNames).
|
||||||
|
func TestTheMeshCLIRequestAndAnswerKeepTheirFieldNames(t *testing.T) {
|
||||||
|
body, _ := json.Marshal(CLIAsked{Line: []string{"status"}, Account: "a", UID: 1})
|
||||||
|
if got := keysIn(t, body); got != "account line uid" {
|
||||||
|
t.Fatalf("the request's fields are %q", got)
|
||||||
|
}
|
||||||
|
body, _ = json.Marshal(CLIAnswer{Stdout: []byte("o"), Stderr: []byte("e"), Exit: 1, Terminal: true, Why: "w",
|
||||||
|
Refused: "r", Cut: true})
|
||||||
|
if got := keysIn(t, body); got != "cut exit refused stderr stdout terminal why" {
|
||||||
|
t.Fatalf("the answer's fields are %q", got)
|
||||||
|
}
|
||||||
|
body, _ = json.Marshal(CLIAsked{Follow: "call-1", Account: "a", UID: 1})
|
||||||
|
if got := keysIn(t, body); got != "account follow uid" {
|
||||||
|
t.Fatalf("a follow's fields are %q", got)
|
||||||
|
}
|
||||||
|
// What a line still running answers, in the envelope every call's answer is in: `result`, then these.
|
||||||
|
var env struct {
|
||||||
|
Result json.RawMessage `json:"result"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(running(&Call{ID: "call-1", Seat: CLISeat, Verb: "a"}, AnswerWithin, false, ""), &env)
|
||||||
|
if got := keysIn(t, env.Result); got != "call output running started" {
|
||||||
|
t.Fatalf("a running answer's fields are %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func keysIn(t *testing.T, body []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(body, &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var keys []string
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return strings.Join(keys, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A node's mesh-cli subject names the node, and no other subject does.
|
||||||
|
func TestTheMeshCLISubjectNamesItsNode(t *testing.T) {
|
||||||
|
if node, ok := CLINode(CLISubject("laptop")); !ok || node != "laptop" {
|
||||||
|
t.Fatalf("CLINode(%q) = %q %v", CLISubject("laptop"), node, ok)
|
||||||
|
}
|
||||||
|
for _, s := range []string{"mesh.control.laptop.report", "mesh.control..cli", "mesh.control.a.b.cli", "mesh.node.a.cli"} {
|
||||||
|
if _, ok := CLINode(s); ok {
|
||||||
|
t.Fatalf("%s was read as a mesh-cli subject", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An answer larger than one bus message is cut to fit, and the cut is said.
|
||||||
|
func TestALargeMeshCLIAnswerIsCutAndSaysSo(t *testing.T) {
|
||||||
|
a := CLIAnswer{Stdout: []byte(strings.Repeat("o", 200000)), Stderr: []byte(strings.Repeat("e", 1000)), Why: "the terminal"}
|
||||||
|
body := FitCLIAnswer(a, 64<<10)
|
||||||
|
if len(body) > 64<<10 {
|
||||||
|
t.Fatalf("the answer is %d bytes, over the bound", len(body))
|
||||||
|
}
|
||||||
|
var got CLIAnswer
|
||||||
|
if err := json.Unmarshal(body, &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !got.Cut || got.Why != "the terminal" || len(got.Stdout) == 0 || string(got.Stderr) != strings.Repeat("e", 1000) {
|
||||||
|
t.Fatalf("the cut answer is %+v", got)
|
||||||
|
}
|
||||||
|
if small := FitCLIAnswer(CLIAnswer{Stdout: []byte("ok")}, 64<<10); strings.Contains(string(small), `"cut"`) {
|
||||||
|
t.Fatal("an answer that fits was said to be cut")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cliBus serves mesh-cli on a bus of the test's own with a call log of its own, and returns a connection to ask on.
|
||||||
|
func cliBus(t *testing.T, l *CallLog, atOnce int, handle CLIHandler) *nats.Conn {
|
||||||
|
t.Helper()
|
||||||
|
conn, err := nats.Connect(testbus.URL(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(conn.Close)
|
||||||
|
stop, err := OverNATS{Conn: conn}.serveCLI(l, atOnce, handle, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(stop)
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// askCLI asks one request on a node's subject and reads the envelope.
|
||||||
|
func askCLI(t *testing.T, conn *nats.Conn, node string, asked CLIAsked) (CLIAnswer, map[string]any, string) {
|
||||||
|
t.Helper()
|
||||||
|
body, _ := json.Marshal(asked)
|
||||||
|
msg, err := conn.Request(CLISubject(node), body, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var env struct {
|
||||||
|
Result json.RawMessage `json:"result"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(msg.Data, &env); err != nil {
|
||||||
|
t.Fatalf("not an envelope: %s", msg.Data)
|
||||||
|
}
|
||||||
|
var a CLIAnswer
|
||||||
|
var raw map[string]any
|
||||||
|
_ = json.Unmarshal(env.Result, &a)
|
||||||
|
_ = json.Unmarshal(env.Result, &raw)
|
||||||
|
return a, raw, env.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// **A line that outlasts the bus's window for an answer is followed to its answer, never lost** (review of ADR
|
||||||
|
// 0272): the first answer says it is running and names its call, and following the call by the same account on
|
||||||
|
// the same node gives what the line printed once it ends. Another account, or another node, is told no such call.
|
||||||
|
func TestALongMeshCLILineIsFollowedToItsAnswer(t *testing.T) {
|
||||||
|
was := AnswerWithin
|
||||||
|
AnswerWithin = 50 * time.Millisecond
|
||||||
|
t.Cleanup(func() { AnswerWithin = was })
|
||||||
|
release := make(chan struct{})
|
||||||
|
conn := cliBus(t, NewCallLog(), 4, func(ctx context.Context, node string, asked CLIAsked) CLIAnswer {
|
||||||
|
<-release
|
||||||
|
return CLIAnswer{Stdout: []byte("done on " + node), Terminal: true}
|
||||||
|
})
|
||||||
|
_, first, failed := askCLI(t, conn, "laptop", CLIAsked{Line: []string{"push", "laptop"}, Account: "op", UID: 1000})
|
||||||
|
call, _ := first["call"].(string)
|
||||||
|
if failed != "" || first["running"] != true || call == "" {
|
||||||
|
t.Fatalf("a long line was not answered as running with its call: %v %q", first, failed)
|
||||||
|
}
|
||||||
|
_, still, _ := askCLI(t, conn, "laptop", CLIAsked{Follow: call, Account: "op", UID: 1000})
|
||||||
|
if still["running"] != true {
|
||||||
|
t.Fatalf("following a running line answered %v", still)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for {
|
||||||
|
a, raw, failed := askCLI(t, conn, "laptop", CLIAsked{Follow: call, Account: "op", UID: 1000})
|
||||||
|
if failed != "" {
|
||||||
|
t.Fatalf("following answered %q", failed)
|
||||||
|
}
|
||||||
|
if raw["running"] != true {
|
||||||
|
if string(a.Stdout) != "done on laptop" || !a.Terminal {
|
||||||
|
t.Fatalf("followed to %+v", a)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("the line never finished for its follower")
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if _, _, failed := askCLI(t, conn, "laptop", CLIAsked{Follow: call, Account: "agent", UID: 1001}); failed == "" {
|
||||||
|
t.Fatal("another account followed the operator's line")
|
||||||
|
}
|
||||||
|
if _, _, failed := askCLI(t, conn, "desktop", CLIAsked{Follow: call, Account: "op", UID: 1000}); failed == "" {
|
||||||
|
t.Fatal("another node followed the line")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lines running at once are bounded: one over the bound is answered busy at once, and nothing ran.
|
||||||
|
func TestMeshCLILinesAtOnceAreBounded(t *testing.T) {
|
||||||
|
was := AnswerWithin
|
||||||
|
AnswerWithin = 50 * time.Millisecond
|
||||||
|
t.Cleanup(func() { AnswerWithin = was })
|
||||||
|
release := make(chan struct{})
|
||||||
|
t.Cleanup(func() { close(release) })
|
||||||
|
ran := make(chan struct{}, 4)
|
||||||
|
conn := cliBus(t, NewCallLog(), 1, func(context.Context, string, CLIAsked) CLIAnswer {
|
||||||
|
ran <- struct{}{}
|
||||||
|
<-release
|
||||||
|
return CLIAnswer{}
|
||||||
|
})
|
||||||
|
if _, first, _ := askCLI(t, conn, "laptop", CLIAsked{Line: []string{"status"}, Account: "op"}); first["running"] != true {
|
||||||
|
t.Fatalf("the first line answered %v", first)
|
||||||
|
}
|
||||||
|
a, _, _ := askCLI(t, conn, "laptop", CLIAsked{Line: []string{"status"}, Account: "op"})
|
||||||
|
if !strings.Contains(a.Refused, "busy") || a.Exit == 0 {
|
||||||
|
t.Fatalf("a line over the bound answered %+v", a)
|
||||||
|
}
|
||||||
|
if len(ran) != 1 {
|
||||||
|
t.Fatalf("%d lines ran, one was bound", len(ran))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mesh-cli line's record keeps its first word, never the rest of its line, and its answer is never kept on the
|
||||||
|
// bus, where `calls` answers anyone who may call the seat.
|
||||||
|
func TestAMeshCLIRecordKeepsNeitherItsLineNorItsAnswerOnTheBus(t *testing.T) {
|
||||||
|
l, a := NewCallLog(), newAnswers(t)
|
||||||
|
writes := make(chan Call, 4)
|
||||||
|
l.writes = writes
|
||||||
|
asked, _ := json.Marshal(CLIAsked{Line: []string{"settings", "set", "x", `{"password":"s3cret"}`}, Account: "op"})
|
||||||
|
l.serveCall(CLISeat, "laptop", asked, "_INBOX.node.laptop.abcdefghijklmnopqrstuv",
|
||||||
|
func(context.Context, json.RawMessage) (any, error) {
|
||||||
|
return CLIAnswer{Stdout: []byte("s3cret-join")}, nil
|
||||||
|
},
|
||||||
|
a.respond, nil)
|
||||||
|
_ = a.only()
|
||||||
|
close(writes)
|
||||||
|
for c := range writes {
|
||||||
|
if carries(c.Args, "s3cret") || carries(c.Answer, "s3cret-join") {
|
||||||
|
t.Fatalf("the bus was sent %s / %s", c.Args, c.Answer)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(c.Args), "settings") || !strings.Contains(string(c.Args), `"op"`) {
|
||||||
|
t.Fatalf("the record does not say what was asked and by whom: %s", c.Args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `calls` answers anyone who may call the seat, agents among them: a mesh-cli line's answer is never shown there,
|
||||||
|
// even from the memory of the controller that ran it; every other call's is (review of ADR 0272).
|
||||||
|
func TestCallsNeverShowsAMeshCLILinesAnswer(t *testing.T) {
|
||||||
|
l, a := NewCallLog(), newAnswers(t)
|
||||||
|
asked, _ := json.Marshal(CLIAsked{Line: []string{"token", "issue", "x"}, Account: "operator"})
|
||||||
|
l.serveCall(CLISeat, "control", asked, "_INBOX.node.control.abcdefghijklmnopqrstuv",
|
||||||
|
func(context.Context, json.RawMessage) (any, error) {
|
||||||
|
return CLIAnswer{Stdout: []byte("s3cret-join")}, nil
|
||||||
|
},
|
||||||
|
a.respond, nil)
|
||||||
|
_ = a.only()
|
||||||
|
recent, _ := l.Recent()
|
||||||
|
shown, found, err := l.Shown(recent[0].ID)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("the line is not shown at all: %v %v", found, err)
|
||||||
|
}
|
||||||
|
if carries(shown.Answer, "s3cret-join") {
|
||||||
|
t.Fatalf("calls shows a mesh-cli line's answer: %s", shown.Answer)
|
||||||
|
}
|
||||||
|
b := newAnswers(t)
|
||||||
|
l.serveCall("mesh-controller", "status", nil, "_INBOX.x.abcdefghijklmnopqrstuv",
|
||||||
|
func(context.Context, json.RawMessage) (any, error) { return "all well", nil }, b.respond, nil)
|
||||||
|
_ = b.only()
|
||||||
|
recent, _ = l.Recent()
|
||||||
|
if shown, _, _ := l.Shown(recent[0].ID); !strings.Contains(string(shown.Answer), "all well") {
|
||||||
|
t.Fatalf("another call's answer is withheld: %s", shown.Answer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// carries says whether a record holds a secret as text or as the base64 JSON writes bytes in: a line's output is
|
||||||
|
// bytes, so a search for its text alone finds nothing whatever the record keeps (review of ADR 0272).
|
||||||
|
func carries(body []byte, secret string) bool {
|
||||||
|
return strings.Contains(string(body), secret) ||
|
||||||
|
strings.Contains(string(body), base64.StdEncoding.EncodeToString([]byte(secret)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two tests above hold what they claim: each fails when the protection it names is taken away.
|
||||||
|
func TestTheWithholdingTestsHoldSomething(t *testing.T) {
|
||||||
|
// The answer as a mesh-cli line's record would carry it, were it kept: the search finds it.
|
||||||
|
body, _ := json.Marshal(map[string]any{"result": CLIAnswer{Stdout: []byte("s3cret-join")}})
|
||||||
|
if !carries(body, "s3cret-join") {
|
||||||
|
t.Fatalf("a record carrying the answer is not found carrying it: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user