diff --git a/cmd/mesh-controller/main.go b/cmd/mesh-controller/main.go index b480107b..2c03372a 100644 --- a/cmd/mesh-controller/main.go +++ b/cmd/mesh-controller/main.go @@ -216,6 +216,10 @@ func run() error { func usage() { fmt.Fprint(os.Stderr, `mesh-controller — the control plane + On a node the operator types these as 'mesh-cli ' (in zsh, 'nox '): 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 prepare the same, asked the way the mesh asks any module (ADR 0135) node add [--adopted] create a node record; --adopted: the machine is in use diff --git a/cmd/mesh-controller/meshcli.go b/cmd/mesh-controller/meshcli.go new file mode 100644 index 00000000..0e4cfa1d --- /dev/null +++ b/cmd/mesh-controller/meshcli.go @@ -0,0 +1,157 @@ +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 `), "+ + "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)) + } +} + +// runForMeshCLI runs a judged line and answers what it said. +func runForMeshCLI(ctx context.Context, node string, asked link.CLIAsked, v cliVerdict) link.CLIAnswer { + 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 := "" + if !v.terminal { + if err := refusedAsTheGenericCommand(asked.Line); err != nil { + return link.CLIAnswer{Exit: 1, Why: v.why, Refused: err.Error()} + } + verb = cliVerb + } + cmd := selfCommand(ctx, asked.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 +} diff --git a/cmd/mesh-controller/meshcli_test.go b/cmd/mesh-controller/meshcli_test.go new file mode 100644 index 00000000..e7b7eed2 --- /dev/null +++ b/cmd/mesh-controller/meshcli_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "context" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// 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") + 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") { + 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"`) { + 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, "settings", "set", "claude-code", "{}"), ordinary) + if a.Refused == "" || len(a.Stdout) != 0 || a.Exit != 1 { + t.Fatalf("settings set ran as an ordinary call: %+v", a) + } + if !strings.Contains(a.Refused, "controller's terminal") || a.Why != "not the terminal" { + t.Fatalf("the refusal does not say why: %+v", a) + } + if err := refusedAsTheGenericCommand([]string{"settings", "set", "x", "{}"}); err == nil { + t.Fatal("the command verb no longer refuses settings set, and mesh-cli's ordinary call relies on it") + } + 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) + } +} diff --git a/cmd/mesh-controller/modules.go b/cmd/mesh-controller/modules.go index 9f4d306d..51128962 100644 --- a/cmd/mesh-controller/modules.go +++ b/cmd/mesh-controller/modules.go @@ -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). 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 "+ "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", @@ -1119,7 +1119,7 @@ func refuseTerminalSettingsThroughAVerb(ctx context.Context, inv *inventory.Inve if string(was) == string(now) { 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 "+ "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; "+ diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index a4f54d63..8a1360c5 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -264,6 +264,12 @@ func serve(ctx context.Context) (err error) { return err } 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. stopAnnouncing, err := bus.Announce(seatAnnouncement(handlers), log.New(os.Stdout, "", log.LstdFlags)) if err != nil { diff --git a/cmd/mesh-controller/sayable_test.go b/cmd/mesh-controller/sayable_test.go index 6fb86b7c..9360e528 100644 --- a/cmd/mesh-controller/sayable_test.go +++ b/cmd/mesh-controller/sayable_test.go @@ -26,6 +26,12 @@ import ( // 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. 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\n", os.Getenv("MESH_VERB"), os.Getenv("MESH_CALLER")) + os.Exit(0) + } conditions.Unsayable = func(o conditions.Observation, field string, r outward.Refusal) { unsaid.note(o, field, r) } diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index baf7f5b3..b0ba7b01 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -226,6 +226,30 @@ func quoteAll(xs []string) string { 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 errors.New("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 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 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) { @@ -242,22 +266,8 @@ func (a *verbArguments) commandLine() ([]string, error) { if err != nil { return nil, err } - if len(argv) == 0 { - 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, errors.New("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 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 to the command "+ - "line (recorded in the hand-act log). Nothing was done", repair) + if err := refusedAsTheGenericCommand(argv); err != nil { + return nil, err } return argv, nil case "tools": @@ -911,6 +921,25 @@ 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. +func commandEnvironment(caller, verb string) []string { + env := make([]string, 0, len(os.Environ())+2) + for _, kv := range os.Environ() { + if strings.HasPrefix(kv, verbVar+"=") || strings.HasPrefix(kv, link.CallerVar+"=") { + continue + } + env = append(env, kv) + } + env = append(env, link.CallerVar+"="+caller) + if verb != "" { + env = append(env, verbVar+"="+verb) + } + 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** @@ -924,21 +953,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) } 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). caller := link.CallerIn(ctx) if caller == "" { 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). verb, _ := ctx.Value(verbKey{}).(string) if verb == "" { 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 // 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 diff --git a/internal/link/meshcli.go b/internal/link/meshcli.go new file mode 100644 index 00000000..a834f225 --- /dev/null +++ b/internal/link/meshcli.go @@ -0,0 +1,130 @@ +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..>`): 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" } + +// CLIAsked is a line mesh-cli was given on a node, and the account the node-engine says is asking. +type CLIAsked struct { + Line []string `json:"line"` + Account string `json:"account"` + UID uint32 `json:"uid"` +} + +// CLIAnswer is what the controller answers: what the command printed, how it exited, whether it ran as the +// controller's terminal and why, or why nothing ran. The node-engine hands it to mesh-cli as it is. +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. Each line on its own goroutine, bounded by HandlerTimeout, and its answer cut to one bus message. +func (b OverNATS) ServeCLI(handle CLIHandler, logger *log.Logger) (func(), error) { + done := make(chan struct{}) + bind := func() (*nats.Subscription, error) { + return b.Conn.QueueSubscribe(CLISubjects, "mesh-cli", func(msg *nats.Msg) { + go b.answerCLI(msg, 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 +} + +func (b OverNATS) answerCLI(msg *nats.Msg, handle CLIHandler, logger *log.Logger) { + if msg.Reply == "" { + return + } + var answer CLIAnswer + node, ok := CLINode(msg.Subject) + var asked CLIAsked + switch { + case !ok: + answer = CLIRefusal("not a mesh-cli subject: " + msg.Subject) + case json.Unmarshal(msg.Data, &asked) != nil || len(asked.Line) == 0: + answer = CLIRefusal("the node-engine's request could not be read, so nothing ran") + default: + ctx, cancel := context.WithTimeout(context.Background(), HandlerTimeout) + answer = handle(ctx, node, asked) + cancel() + } + body := FitCLIAnswer(answer, b.Conn.MaxPayload()) + if err := msg.Respond(body); err != nil && logger != nil { + logger.Printf("mesh-cli on %s: the answer could not be sent: %v", node, err) + } +} + +// FitCLIAnswer is the answer as one bus message of at most limit bytes: what the command printed is cut, standard +// output first, and the cut is said (ADR 0272 §5) — never silently short. +func FitCLIAnswer(a CLIAnswer, limit int64) []byte { + body, _ := json.Marshal(a) + if limit <= 0 || int64(len(body)) <= limit { + return body + } + 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] + } + body, _ = json.Marshal(a) + return body +} diff --git a/internal/link/meshcli_test.go b/internal/link/meshcli_test.go new file mode 100644 index 00000000..b56f20f2 --- /dev/null +++ b/internal/link/meshcli_test.go @@ -0,0 +1,67 @@ +package link + +import ( + "encoding/json" + "sort" + "strings" + "testing" +) + +// 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) + } +} + +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") + } +}