go/cli: the local protocol mesh-cli speaks to the node-engine (hq ADR 0272)
mesh-cli holds no credential: it asks the node-engine on its own machine over a socket, and the node-engine reads the account from the kernel. The socket's path, the request and the answer are shared by the program and the node-engine, so they live here once (ADR 0039: a local protocol a module's program speaks to its runtime). The bus request the node-engine then makes is the transport's, and stays out of the SDK.
This commit is contained in:
+3
-2
@@ -14,5 +14,6 @@ GOPRIVATE=git.novox.be go get git.novox.be/novox/mesh-sdk/go@latest
|
||||
Releases are tagged `go/vX.Y.Z` in this repository, the subdirectory form Go's module system expects,
|
||||
at the same version as the TypeScript package.
|
||||
|
||||
What it holds, under ADR 0039's test: the stdio loop and the tool type. Nothing else — no bus, no
|
||||
client, nothing of any module's. `go build ./example` and pipe a `tools/list` request into it.
|
||||
What it holds, under ADR 0039's test: the stdio loop and the tool type, and `cli`, the local protocol
|
||||
`mesh-cli` speaks to the node-engine on its machine (novox/hq ADR 0272): the socket's path and one request and one
|
||||
answer. Nothing else — no bus, no client, nothing of any module's. `go build ./example` and pipe a `tools/list` request into it.
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
// Package cli is the local protocol between `mesh-cli` and the node-engine on its machine (novox/hq ADR 0272).
|
||||
//
|
||||
// `mesh-cli <command line>` asks the controller to run one of its command lines. It holds no credential and
|
||||
// reads no file of the mesh: it connects to the node-engine's socket, writes one Request, and reads one Answer.
|
||||
// The node-engine reads the connecting process's account from the kernel and asks the controller over its own
|
||||
// link; that request is the node-engine's and the controller's, and lives in neither this package nor any SDK
|
||||
// (ADR 0039 as ADR 0188 reads it: the transport lives in the runtime).
|
||||
//
|
||||
// One request and one answer per connection, each one line of JSON. Standard library only.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Socket is where the node-engine listens for mesh-cli: a file under its runtime directory, with no network
|
||||
// address (ADR 0272 §3).
|
||||
const Socket = "/run/nox-mesh-host/cli.sock"
|
||||
|
||||
// MaxRequest bounds what the node-engine reads of a request. A command line is a few hundred bytes; settings given
|
||||
// inline are a few kilobytes.
|
||||
const MaxRequest = 64 << 10
|
||||
|
||||
// MaxAnswer bounds what mesh-cli reads of an answer. The controller cuts what it runs to fit one bus message, and
|
||||
// says so; this is the local bound above that.
|
||||
const MaxAnswer = 16 << 20
|
||||
|
||||
// AskWithin is how long mesh-cli waits for its answer: the controller's own bound on a call (five minutes),
|
||||
// and a minute for the link on either side.
|
||||
const AskWithin = 6 * time.Minute
|
||||
|
||||
// Request is what mesh-cli asks: the controller's command line, as words.
|
||||
type Request struct {
|
||||
Line []string `json:"line"`
|
||||
}
|
||||
|
||||
// Answer is what comes back: what the command printed, how it exited, and whether it ran as the controller's
|
||||
// terminal and why. Refused is set when nothing ran at all, and says why; Exit is then 1.
|
||||
type Answer struct {
|
||||
Stdout []byte `json:"stdout,omitempty"`
|
||||
Stderr []byte `json:"stderr,omitempty"`
|
||||
Exit int `json:"exit"`
|
||||
Terminal bool `json:"terminal"`
|
||||
// Why says whether the line ran as the terminal, and why or why not, in the controller's words.
|
||||
Why string `json:"why,omitempty"`
|
||||
// Refused says why nothing ran: the account, the node, the controller unreachable.
|
||||
Refused string `json:"refused,omitempty"`
|
||||
// Cut says the output was longer than one bus message and was cut there.
|
||||
Cut bool `json:"cut,omitempty"`
|
||||
}
|
||||
|
||||
// Refusal is an answer saying that nothing ran, and why.
|
||||
func Refusal(why string) Answer { return Answer{Exit: 1, Refused: why} }
|
||||
|
||||
// ErrTooLong is a request or an answer over its bound.
|
||||
var ErrTooLong = errors.New("longer than the protocol allows")
|
||||
|
||||
// WriteRequest writes one request, as one line.
|
||||
func WriteRequest(w io.Writer, r Request) error { return writeLine(w, r) }
|
||||
|
||||
// ReadRequest reads one request, refusing one over MaxRequest or one that names no command.
|
||||
func ReadRequest(r io.Reader) (Request, error) {
|
||||
var req Request
|
||||
if err := readLine(r, MaxRequest, &req); err != nil {
|
||||
return Request{}, err
|
||||
}
|
||||
if len(req.Line) == 0 {
|
||||
return Request{}, errors.New("the request names no command")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// WriteAnswer writes one answer, as one line.
|
||||
func WriteAnswer(w io.Writer, a Answer) error { return writeLine(w, a) }
|
||||
|
||||
// ReadAnswer reads one answer, refusing one over MaxAnswer.
|
||||
func ReadAnswer(r io.Reader) (Answer, error) {
|
||||
var a Answer
|
||||
err := readLine(r, MaxAnswer, &a)
|
||||
return a, err
|
||||
}
|
||||
|
||||
// Ask is mesh-cli's side: connect to the socket, ask, and wait for the answer within AskWithin or ctx.
|
||||
func Ask(ctx context.Context, socket string, line []string) (Answer, error) {
|
||||
if len(line) == 0 {
|
||||
return Answer{}, errors.New("no command given")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, AskWithin)
|
||||
defer cancel()
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(ctx, "unix", socket)
|
||||
if err != nil {
|
||||
return Answer{}, fmt.Errorf("the node-engine on this machine does not answer at %s: %w", socket, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(deadline)
|
||||
}
|
||||
if err := WriteRequest(conn, Request{Line: line}); err != nil {
|
||||
return Answer{}, fmt.Errorf("asking the node-engine: %w", err)
|
||||
}
|
||||
a, err := ReadAnswer(conn)
|
||||
if err != nil {
|
||||
return Answer{}, fmt.Errorf("reading the node-engine's answer: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func writeLine(w io.Writer, v any) error {
|
||||
body, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(append(body, '\n'))
|
||||
return err
|
||||
}
|
||||
|
||||
// readLine reads one line of JSON of at most limit bytes. A line over the limit is refused rather than read
|
||||
// whole: the reader is the side that pays for it.
|
||||
func readLine(r io.Reader, limit int, into any) error {
|
||||
br := bufio.NewReaderSize(io.LimitReader(r, int64(limit)+1), 64<<10)
|
||||
var body []byte
|
||||
for {
|
||||
chunk, err := br.ReadSlice('\n')
|
||||
body = append(body, chunk...)
|
||||
if len(body) > limit {
|
||||
return fmt.Errorf("%w: more than %d bytes", ErrTooLong, limit)
|
||||
}
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if errors.Is(err, bufio.ErrBufferFull) {
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, io.EOF) && len(body) > 0 {
|
||||
break
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(body, into); err != nil {
|
||||
return fmt.Errorf("not one: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A request and an answer survive a round trip, bytes and all.
|
||||
func TestARequestAndAnAnswerSurviveTheirRoundTrip(t *testing.T) {
|
||||
var b bytes.Buffer
|
||||
line := []string{"settings", "set", "zsh", `{"execute":"withhold"}`, "--node", "a"}
|
||||
if err := WriteRequest(&b, Request{Line: line}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ReadRequest(&b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(got.Line, "\x00") != strings.Join(line, "\x00") {
|
||||
t.Fatalf("the line came back as %q", got.Line)
|
||||
}
|
||||
|
||||
b.Reset()
|
||||
sent := Answer{Stdout: []byte("ok\n\xff"), Stderr: []byte("said"), Exit: 3, Terminal: true, Why: "the terminal", Cut: true}
|
||||
if err := WriteAnswer(&b, sent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, err := ReadAnswer(&b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(a.Stdout, sent.Stdout) || string(a.Stderr) != "said" || a.Exit != 3 || !a.Terminal || a.Why != sent.Why || !a.Cut {
|
||||
t.Fatalf("the answer came back as %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// A request over the bound is refused, not read whole; one naming no command is refused.
|
||||
func TestARequestOverTheBoundOrNamingNothingIsRefused(t *testing.T) {
|
||||
long := Request{Line: []string{strings.Repeat("x", MaxRequest)}}
|
||||
var b bytes.Buffer
|
||||
if err := WriteRequest(&b, long); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadRequest(&b); !errors.Is(err, ErrTooLong) {
|
||||
t.Fatalf("a request over the bound was read: %v", err)
|
||||
}
|
||||
if _, err := ReadRequest(strings.NewReader(`{"line":[]}` + "\n")); err == nil {
|
||||
t.Fatal("a request naming no command was taken")
|
||||
}
|
||||
if _, err := ReadRequest(strings.NewReader("not json\n")); err == nil {
|
||||
t.Fatal("a request that is not one was taken")
|
||||
}
|
||||
}
|
||||
|
||||
// Ask speaks the protocol over a socket, as mesh-cli does to the node-engine.
|
||||
func TestAskSpeaksTheProtocolOverASocket(t *testing.T) {
|
||||
socket := filepath.Join(t.TempDir(), "cli.sock")
|
||||
l, err := net.Listen("unix", socket)
|
||||
if err != nil {
|
||||
t.Skipf("no unix socket here: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
go func() {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
req, err := ReadRequest(conn)
|
||||
if err != nil {
|
||||
_ = WriteAnswer(conn, Refusal(err.Error()))
|
||||
return
|
||||
}
|
||||
_ = WriteAnswer(conn, Answer{Stdout: []byte(strings.Join(req.Line, " "))})
|
||||
}()
|
||||
a, err := Ask(context.Background(), socket, []string{"status"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(a.Stdout) != "status" || a.Exit != 0 || a.Refused != "" {
|
||||
t.Fatalf("asked status and heard %+v", a)
|
||||
}
|
||||
if _, err := Ask(context.Background(), filepath.Join(t.TempDir(), "none.sock"), []string{"status"}); err == nil ||
|
||||
!strings.Contains(err.Error(), "does not answer") {
|
||||
t.Fatalf("a missing socket was not said: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user