Files
mesh-sdk/go/cli/cli.go
T
jochen bfe82315f4 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.
2026-10-09 12:03:14 +02:00

152 lines
5.1 KiB
Go

// 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
}