First stab at maglevc
This commit is contained in:
7
Makefile
7
Makefile
@@ -1,4 +1,4 @@
|
|||||||
BINARY := maglevd
|
BINARIES := maglevd maglevc
|
||||||
MODULE := git.ipng.ch/ipng/vpp-maglev
|
MODULE := git.ipng.ch/ipng/vpp-maglev
|
||||||
PROTO_DIR := proto
|
PROTO_DIR := proto
|
||||||
PROTO_FILE := $(PROTO_DIR)/maglev.proto
|
PROTO_FILE := $(PROTO_DIR)/maglev.proto
|
||||||
@@ -9,7 +9,8 @@ GEN_FILES := internal/grpcapi/maglev.pb.go internal/grpcapi/maglev_grpc.pb.go
|
|||||||
all: build
|
all: build
|
||||||
|
|
||||||
build: $(GEN_FILES)
|
build: $(GEN_FILES)
|
||||||
go build -o bin/$(BINARY) ./cmd/maglevd/
|
go build -o bin/maglevd ./cmd/maglevd/
|
||||||
|
go build -o bin/maglevc ./cmd/maglevc/
|
||||||
|
|
||||||
test: $(GEN_FILES)
|
test: $(GEN_FILES)
|
||||||
go test ./...
|
go test ./...
|
||||||
@@ -26,5 +27,5 @@ lint:
|
|||||||
golangci-lint run ./...
|
golangci-lint run ./...
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f bin/$(BINARY)
|
rm -f $(addprefix bin/,$(BINARIES))
|
||||||
rm -f $(GEN_FILES)
|
rm -f $(GEN_FILES)
|
||||||
|
|||||||
307
cmd/maglevc/commands.go
Normal file
307
cmd/maglevc/commands.go
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
// Copyright (c) 2026, Pim van Pelt <pim@ipng.ch>
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"text/tabwriter"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ipng.ch/ipng/vpp-maglev/internal/grpcapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
const callTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// buildTree constructs the full command tree.
|
||||||
|
func buildTree() *Node {
|
||||||
|
root := &Node{Word: "", Help: ""}
|
||||||
|
|
||||||
|
show := &Node{Word: "show", Help: "show information"}
|
||||||
|
set := &Node{Word: "set", Help: "modify configuration"}
|
||||||
|
quit := &Node{Word: "quit", Help: "exit the shell", Run: runQuit}
|
||||||
|
exit := &Node{Word: "exit", Help: "exit the shell", Run: runQuit}
|
||||||
|
|
||||||
|
// show frontends
|
||||||
|
showFrontends := &Node{Word: "frontends", Help: "list all frontends", Run: runShowFrontends}
|
||||||
|
// show frontend <name>
|
||||||
|
showFrontendName := &Node{
|
||||||
|
Word: "<name>",
|
||||||
|
Help: "frontend name",
|
||||||
|
Dynamic: dynFrontends,
|
||||||
|
Run: runShowFrontend,
|
||||||
|
}
|
||||||
|
showFrontend := &Node{
|
||||||
|
Word: "frontend",
|
||||||
|
Help: "show a single frontend",
|
||||||
|
Children: []*Node{showFrontendName},
|
||||||
|
}
|
||||||
|
|
||||||
|
// show backends
|
||||||
|
showBackends := &Node{Word: "backends", Help: "list all backends", Run: runShowBackends}
|
||||||
|
// show backend <name>
|
||||||
|
showBackendName := &Node{
|
||||||
|
Word: "<name>",
|
||||||
|
Help: "backend name",
|
||||||
|
Dynamic: dynBackends,
|
||||||
|
Run: runShowBackend,
|
||||||
|
}
|
||||||
|
showBackend := &Node{
|
||||||
|
Word: "backend",
|
||||||
|
Help: "show a single backend",
|
||||||
|
Children: []*Node{showBackendName},
|
||||||
|
}
|
||||||
|
|
||||||
|
// show healthchecks
|
||||||
|
showHealthChecks := &Node{Word: "healthchecks", Help: "list all health checks", Run: runShowHealthChecks}
|
||||||
|
// show healthcheck <name>
|
||||||
|
showHealthCheckName := &Node{
|
||||||
|
Word: "<name>",
|
||||||
|
Help: "health check name",
|
||||||
|
Dynamic: dynHealthChecks,
|
||||||
|
Run: runShowHealthCheck,
|
||||||
|
}
|
||||||
|
showHealthCheck := &Node{
|
||||||
|
Word: "healthcheck",
|
||||||
|
Help: "show a single health check",
|
||||||
|
Children: []*Node{showHealthCheckName},
|
||||||
|
}
|
||||||
|
|
||||||
|
show.Children = []*Node{
|
||||||
|
showFrontends, showFrontend,
|
||||||
|
showBackends, showBackend,
|
||||||
|
showHealthChecks, showHealthCheck,
|
||||||
|
}
|
||||||
|
|
||||||
|
// set backend <name> pause|resume|disabled|enabled
|
||||||
|
setPause := &Node{Word: "pause", Help: "pause health checking", Run: runPauseBackend}
|
||||||
|
setResume := &Node{Word: "resume", Help: "resume health checking", Run: runResumeBackend}
|
||||||
|
setDisabled := &Node{Word: "disabled", Help: "disable backend (not implemented)", Run: runNotImplemented}
|
||||||
|
setEnabled := &Node{Word: "enabled", Help: "enable backend (not implemented)", Run: runNotImplemented}
|
||||||
|
setBackendName := &Node{
|
||||||
|
Word: "<name>",
|
||||||
|
Help: "backend name",
|
||||||
|
Dynamic: dynBackends,
|
||||||
|
Children: []*Node{setPause, setResume, setDisabled, setEnabled},
|
||||||
|
}
|
||||||
|
setBackend := &Node{
|
||||||
|
Word: "backend",
|
||||||
|
Help: "modify a backend",
|
||||||
|
Children: []*Node{setBackendName},
|
||||||
|
}
|
||||||
|
set.Children = []*Node{setBackend}
|
||||||
|
|
||||||
|
root.Children = []*Node{show, set, quit, exit}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- dynamic enumerators ---------------------------------------------------
|
||||||
|
|
||||||
|
func dynFrontends(ctx context.Context, client grpcapi.MaglevClient) []string {
|
||||||
|
resp, err := client.ListFrontends(ctx, &grpcapi.ListFrontendsRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return resp.FrontendNames
|
||||||
|
}
|
||||||
|
|
||||||
|
func dynBackends(ctx context.Context, client grpcapi.MaglevClient) []string {
|
||||||
|
resp, err := client.ListBackends(ctx, &grpcapi.ListBackendsRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return resp.BackendNames
|
||||||
|
}
|
||||||
|
|
||||||
|
func dynHealthChecks(ctx context.Context, client grpcapi.MaglevClient) []string {
|
||||||
|
resp, err := client.ListHealthChecks(ctx, &grpcapi.ListHealthChecksRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return resp.Names
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- run functions ---------------------------------------------------------
|
||||||
|
|
||||||
|
func runQuit(_ context.Context, _ grpcapi.MaglevClient, _ []string) error {
|
||||||
|
return errQuit
|
||||||
|
}
|
||||||
|
|
||||||
|
func runShowFrontends(ctx context.Context, client grpcapi.MaglevClient, _ []string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||||
|
defer cancel()
|
||||||
|
resp, err := client.ListFrontends(ctx, &grpcapi.ListFrontendsRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, name := range resp.FrontendNames {
|
||||||
|
fmt.Println(name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runShowFrontend(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return fmt.Errorf("usage: show frontend <name>")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||||
|
defer cancel()
|
||||||
|
info, err := client.GetFrontend(ctx, &grpcapi.GetFrontendRequest{Name: args[0]})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintf(w, "name\t%s\n", info.Name)
|
||||||
|
fmt.Fprintf(w, "address\t%s\n", info.Address)
|
||||||
|
fmt.Fprintf(w, "protocol\t%s\n", info.Protocol)
|
||||||
|
fmt.Fprintf(w, "port\t%d\n", info.Port)
|
||||||
|
for i, b := range info.BackendNames {
|
||||||
|
if i == 0 {
|
||||||
|
fmt.Fprintf(w, "backends\t%s\n", b)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(w, "\t%s\n", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if info.Description != "" {
|
||||||
|
fmt.Fprintf(w, "description\t%s\n", info.Description)
|
||||||
|
}
|
||||||
|
return w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runShowBackends(ctx context.Context, client grpcapi.MaglevClient, _ []string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||||
|
defer cancel()
|
||||||
|
resp, err := client.ListBackends(ctx, &grpcapi.ListBackendsRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, name := range resp.BackendNames {
|
||||||
|
fmt.Println(name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runShowBackend(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return fmt.Errorf("usage: show backend <name>")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||||
|
defer cancel()
|
||||||
|
info, err := client.GetBackend(ctx, &grpcapi.GetBackendRequest{Name: args[0]})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintf(w, "name\t%s\n", info.Name)
|
||||||
|
fmt.Fprintf(w, "address\t%s\n", info.Address)
|
||||||
|
fmt.Fprintf(w, "state\t%s\n", info.State)
|
||||||
|
fmt.Fprintf(w, "enabled\t%v\n", info.Enabled)
|
||||||
|
fmt.Fprintf(w, "weight\t%d\n", info.Weight)
|
||||||
|
fmt.Fprintf(w, "healthcheck\t%s\n", info.Healthcheck)
|
||||||
|
for i, t := range info.Transitions {
|
||||||
|
if i == 0 {
|
||||||
|
fmt.Fprintf(w, "transitions\t%s → %s\n", t.From, t.To)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(w, "\t%s → %s\n", t.From, t.To)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runShowHealthChecks(ctx context.Context, client grpcapi.MaglevClient, _ []string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||||
|
defer cancel()
|
||||||
|
resp, err := client.ListHealthChecks(ctx, &grpcapi.ListHealthChecksRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, name := range resp.Names {
|
||||||
|
fmt.Println(name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runShowHealthCheck(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return fmt.Errorf("usage: show healthcheck <name>")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||||
|
defer cancel()
|
||||||
|
info, err := client.GetHealthCheck(ctx, &grpcapi.GetHealthCheckRequest{Name: args[0]})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintf(w, "name\t%s\n", info.Name)
|
||||||
|
fmt.Fprintf(w, "type\t%s\n", info.Type)
|
||||||
|
if info.Port > 0 {
|
||||||
|
fmt.Fprintf(w, "port\t%d\n", info.Port)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "interval\t%s\n", time.Duration(info.IntervalNs))
|
||||||
|
if info.FastIntervalNs > 0 {
|
||||||
|
fmt.Fprintf(w, "fast-interval\t%s\n", time.Duration(info.FastIntervalNs))
|
||||||
|
}
|
||||||
|
if info.DownIntervalNs > 0 {
|
||||||
|
fmt.Fprintf(w, "down-interval\t%s\n", time.Duration(info.DownIntervalNs))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "timeout\t%s\n", time.Duration(info.TimeoutNs))
|
||||||
|
fmt.Fprintf(w, "rise\t%d\n", info.Rise)
|
||||||
|
fmt.Fprintf(w, "fall\t%d\n", info.Fall)
|
||||||
|
if info.ProbeIpv4Src != "" {
|
||||||
|
fmt.Fprintf(w, "probe-ipv4-src\t%s\n", info.ProbeIpv4Src)
|
||||||
|
}
|
||||||
|
if info.ProbeIpv6Src != "" {
|
||||||
|
fmt.Fprintf(w, "probe-ipv6-src\t%s\n", info.ProbeIpv6Src)
|
||||||
|
}
|
||||||
|
if h := info.Http; h != nil {
|
||||||
|
fmt.Fprintf(w, "http.path\t%s\n", h.Path)
|
||||||
|
if h.Host != "" {
|
||||||
|
fmt.Fprintf(w, "http.host\t%s\n", h.Host)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "http.response-code\t%d-%d\n", h.ResponseCodeMin, h.ResponseCodeMax)
|
||||||
|
if h.ResponseRegexp != "" {
|
||||||
|
fmt.Fprintf(w, "http.response-regexp\t%s\n", h.ResponseRegexp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t := info.Tcp; t != nil {
|
||||||
|
fmt.Fprintf(w, "tcp.ssl\t%v\n", t.Ssl)
|
||||||
|
if t.ServerName != "" {
|
||||||
|
fmt.Fprintf(w, "tcp.server-name\t%s\n", t.ServerName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runPauseBackend(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return fmt.Errorf("usage: set backend <name> pause")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||||
|
defer cancel()
|
||||||
|
info, err := client.PauseBackend(ctx, &grpcapi.PauseResumeRequest{Name: args[0]})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("%s: setting state to '%s'\n", info.Name, info.State)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runResumeBackend(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return fmt.Errorf("usage: set backend <name> resume")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||||
|
defer cancel()
|
||||||
|
info, err := client.ResumeBackend(ctx, &grpcapi.PauseResumeRequest{Name: args[0]})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("%s: setting state to '%s'\n", info.Name, info.State)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runNotImplemented(_ context.Context, _ grpcapi.MaglevClient, _ []string) error {
|
||||||
|
fmt.Println("not implemented yet")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
113
cmd/maglevc/complete.go
Normal file
113
cmd/maglevc/complete.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
// Copyright (c) 2026, Pim van Pelt <pim@ipng.ch>
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/chzyer/readline"
|
||||||
|
|
||||||
|
"git.ipng.ch/ipng/vpp-maglev/internal/grpcapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
const completeTimeout = 1 * time.Second
|
||||||
|
|
||||||
|
// Completer implements readline.AutoCompleter for the command tree.
|
||||||
|
type Completer struct {
|
||||||
|
root *Node
|
||||||
|
client grpcapi.MaglevClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do implements readline.AutoCompleter.
|
||||||
|
// line is the full current line; pos is the cursor position.
|
||||||
|
// Returns (newLine [][]rune, length int) where length is how many rune bytes
|
||||||
|
// before pos should be replaced by each candidate in newLine.
|
||||||
|
func (co *Completer) Do(line []rune, pos int) (newLine [][]rune, length int) {
|
||||||
|
before := string(line[:pos])
|
||||||
|
tokens := splitTokens(before)
|
||||||
|
|
||||||
|
// Determine the partial token being completed.
|
||||||
|
var partial string
|
||||||
|
var prefix []string
|
||||||
|
if len(tokens) == 0 || (len(before) > 0 && before[len(before)-1] == ' ') {
|
||||||
|
// Cursor is after a space — completing a new token.
|
||||||
|
prefix = tokens
|
||||||
|
partial = ""
|
||||||
|
} else {
|
||||||
|
// Cursor is within the last token.
|
||||||
|
prefix = tokens[:len(tokens)-1]
|
||||||
|
partial = tokens[len(tokens)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), completeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
candidates := Candidates(co.root, prefix, partial, ctx, co.client)
|
||||||
|
|
||||||
|
var suffixes [][]rune
|
||||||
|
for _, c := range candidates {
|
||||||
|
suffix := c.Word[len(partial):]
|
||||||
|
suffixes = append(suffixes, []rune(suffix+" "))
|
||||||
|
}
|
||||||
|
return suffixes, len([]rune(partial))
|
||||||
|
}
|
||||||
|
|
||||||
|
// questionListener intercepts the '?' key and prints inline help.
|
||||||
|
type questionListener struct {
|
||||||
|
root *Node
|
||||||
|
client grpcapi.MaglevClient
|
||||||
|
rl *readline.Instance
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ql *questionListener) OnChange(line []rune, pos int, key rune) (newLine []rune, newPos int, ok bool) {
|
||||||
|
if key != '?' {
|
||||||
|
return line, pos, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// line[:pos] includes the '?' just typed — strip it before tokenizing.
|
||||||
|
before := string(line[:pos])
|
||||||
|
if len(before) > 0 && before[len(before)-1] == '?' {
|
||||||
|
before = before[:len(before)-1]
|
||||||
|
}
|
||||||
|
tokens := splitTokens(before)
|
||||||
|
|
||||||
|
var partial string
|
||||||
|
var prefix []string
|
||||||
|
if len(before) == 0 || before[len(before)-1] == ' ' {
|
||||||
|
prefix = tokens
|
||||||
|
partial = ""
|
||||||
|
} else if len(tokens) > 0 {
|
||||||
|
prefix = tokens[:len(tokens)-1]
|
||||||
|
partial = tokens[len(tokens)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), completeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
candidates := Candidates(ql.root, prefix, partial, ctx, ql.client)
|
||||||
|
|
||||||
|
fmt.Fprintf(ql.rl.Stderr(), "\r\n")
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
fmt.Fprintf(ql.rl.Stderr(), " <no completions>\r\n")
|
||||||
|
} else {
|
||||||
|
for _, c := range candidates {
|
||||||
|
if c.Help != "" {
|
||||||
|
fmt.Fprintf(ql.rl.Stderr(), " %-20s %s\r\n", c.Word, c.Help)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(ql.rl.Stderr(), " %s\r\n", c.Word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the '?' from the line and return with cursor one step back.
|
||||||
|
newLine = append(append([]rune{}, line[:pos-1]...), line[pos:]...)
|
||||||
|
return newLine, pos - 1, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitTokens splits a string into whitespace-separated tokens.
|
||||||
|
func splitTokens(s string) []string {
|
||||||
|
return strings.Fields(s)
|
||||||
|
}
|
||||||
49
cmd/maglevc/main.go
Normal file
49
cmd/maglevc/main.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// Copyright (c) 2026, Pim van Pelt <pim@ipng.ch>
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
|
||||||
|
"git.ipng.ch/ipng/vpp-maglev/internal/grpcapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
serverAddr := flag.String("server", "localhost:9090", "maglev server address")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
conn, err := grpc.NewClient(*serverAddr,
|
||||||
|
grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect %s: %w", *serverAddr, err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
client := grpcapi.NewMaglevClient(conn)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
args := flag.Args()
|
||||||
|
if len(args) == 0 {
|
||||||
|
// Interactive shell.
|
||||||
|
return runShell(ctx, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-shot command from CLI arguments.
|
||||||
|
root := buildTree()
|
||||||
|
tokens := splitTokens(strings.Join(args, " "))
|
||||||
|
return dispatch(ctx, root, client, tokens)
|
||||||
|
}
|
||||||
94
cmd/maglevc/shell.go
Normal file
94
cmd/maglevc/shell.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// Copyright (c) 2026, Pim van Pelt <pim@ipng.ch>
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/chzyer/readline"
|
||||||
|
|
||||||
|
"git.ipng.ch/ipng/vpp-maglev/internal/grpcapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errQuit is a sentinel returned by runQuit to exit the REPL.
|
||||||
|
var errQuit = errors.New("quit")
|
||||||
|
|
||||||
|
// runShell runs the interactive REPL until the user types quit/exit or EOF.
|
||||||
|
func runShell(ctx context.Context, client grpcapi.MaglevClient) error {
|
||||||
|
root := buildTree()
|
||||||
|
|
||||||
|
comp := &Completer{root: root, client: client}
|
||||||
|
ql := &questionListener{root: root, client: client}
|
||||||
|
|
||||||
|
cfg := &readline.Config{
|
||||||
|
Prompt: "maglev> ",
|
||||||
|
AutoComplete: comp,
|
||||||
|
InterruptPrompt: "^C",
|
||||||
|
EOFPrompt: "exit",
|
||||||
|
Listener: ql,
|
||||||
|
}
|
||||||
|
rl, err := readline.NewEx(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("readline init: %w", err)
|
||||||
|
}
|
||||||
|
ql.rl = rl
|
||||||
|
defer rl.Close()
|
||||||
|
|
||||||
|
for {
|
||||||
|
line, err := rl.Readline()
|
||||||
|
if err == readline.ErrInterrupt {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens := splitTokens(line)
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := dispatch(ctx, root, client, tokens); err != nil {
|
||||||
|
if errors.Is(err, errQuit) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Fprintf(rl.Stderr(), "error: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatch walks the tree and executes the matched command.
|
||||||
|
func dispatch(ctx context.Context, root *Node, client grpcapi.MaglevClient, tokens []string) error {
|
||||||
|
node, args := Walk(root, tokens)
|
||||||
|
|
||||||
|
if node.Run == nil {
|
||||||
|
showHelp(root, tokens)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return node.Run(ctx, client, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// showHelp prints available completions for the given token path.
|
||||||
|
func showHelp(root *Node, tokens []string) {
|
||||||
|
node, _ := Walk(root, tokens)
|
||||||
|
|
||||||
|
candidates := filterFixedChildren(node.Children, "")
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
fmt.Println(" <no completions>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, c := range candidates {
|
||||||
|
if c.Help != "" {
|
||||||
|
fmt.Printf(" %-20s %s\n", c.Word, c.Help)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" %s\n", c.Word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
127
cmd/maglevc/tree.go
Normal file
127
cmd/maglevc/tree.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
// Copyright (c) 2026, Pim van Pelt <pim@ipng.ch>
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.ipng.ch/ipng/vpp-maglev/internal/grpcapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Node is one word in the command tree. Leaf nodes have a Run function.
|
||||||
|
// Slot nodes have Dynamic set (and no fixed Word to match against); they
|
||||||
|
// accept any single token as an argument and may have further Children.
|
||||||
|
type Node struct {
|
||||||
|
Word string
|
||||||
|
Help string
|
||||||
|
Dynamic func(context.Context, grpcapi.MaglevClient) []string // non-nil → slot node
|
||||||
|
Children []*Node
|
||||||
|
Run func(context.Context, grpcapi.MaglevClient, []string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk descends the tree following tokens. At each step it tries fixed
|
||||||
|
// children first (exact then prefix), then falls back to a slot child
|
||||||
|
// (Dynamic != nil). Tokens consumed by slot children are collected as args.
|
||||||
|
// Returns the deepest node reached and the args collected from slot nodes.
|
||||||
|
func Walk(root *Node, tokens []string) (*Node, []string) {
|
||||||
|
node := root
|
||||||
|
var args []string
|
||||||
|
for len(tokens) > 0 {
|
||||||
|
tok := tokens[0]
|
||||||
|
|
||||||
|
// Try fixed children (exact, then unique prefix).
|
||||||
|
next := matchFixedChild(node.Children, tok)
|
||||||
|
if next != nil {
|
||||||
|
node = next
|
||||||
|
tokens = tokens[1:]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try a slot child.
|
||||||
|
slot := findSlotChild(node.Children)
|
||||||
|
if slot != nil {
|
||||||
|
args = append(args, tok)
|
||||||
|
tokens = tokens[1:]
|
||||||
|
node = slot
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dead end — no match.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return node, args
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchFixedChild returns the child matching tok by exact then unique prefix,
|
||||||
|
// considering only non-slot children.
|
||||||
|
func matchFixedChild(children []*Node, tok string) *Node {
|
||||||
|
var fixed []*Node
|
||||||
|
for _, c := range children {
|
||||||
|
if c.Dynamic == nil {
|
||||||
|
fixed = append(fixed, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Exact match.
|
||||||
|
for _, c := range fixed {
|
||||||
|
if c.Word == tok {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Unique prefix match.
|
||||||
|
var matches []*Node
|
||||||
|
for _, c := range fixed {
|
||||||
|
if strings.HasPrefix(c.Word, tok) {
|
||||||
|
matches = append(matches, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(matches) == 1 {
|
||||||
|
return matches[0]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findSlotChild returns the first child that is a slot node (Dynamic != nil).
|
||||||
|
func findSlotChild(children []*Node) *Node {
|
||||||
|
for _, c := range children {
|
||||||
|
if c.Dynamic != nil {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidates returns the completable children at the current position given
|
||||||
|
// the already-typed tokens and the partial token being completed.
|
||||||
|
func Candidates(root *Node, tokens []string, partial string, ctx context.Context, client grpcapi.MaglevClient) []*Node {
|
||||||
|
// Walk the already-confirmed tokens.
|
||||||
|
node, _ := Walk(root, tokens)
|
||||||
|
|
||||||
|
// Now look at what could follow at this node.
|
||||||
|
// Check fixed children filtered by partial.
|
||||||
|
fixedMatches := filterFixedChildren(node.Children, partial)
|
||||||
|
|
||||||
|
// Check dynamic slot child if present.
|
||||||
|
var dynMatches []*Node
|
||||||
|
slot := findSlotChild(node.Children)
|
||||||
|
if slot != nil && slot.Dynamic != nil {
|
||||||
|
vals := slot.Dynamic(ctx, client)
|
||||||
|
for _, v := range vals {
|
||||||
|
if strings.HasPrefix(v, partial) {
|
||||||
|
dynMatches = append(dynMatches, &Node{Word: v, Help: slot.Help})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return append(fixedMatches, dynMatches...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterFixedChildren(children []*Node, prefix string) []*Node {
|
||||||
|
var out []*Node
|
||||||
|
for _, c := range children {
|
||||||
|
if c.Dynamic == nil && strings.HasPrefix(c.Word, prefix) {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
1
go.mod
1
go.mod
@@ -11,6 +11,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/chzyer/readline v1.5.1 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/text v0.35.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
|
||||||
|
|||||||
5
go.sum
5
go.sum
@@ -1,5 +1,9 @@
|
|||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
|
||||||
|
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
|
||||||
|
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
|
||||||
|
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
@@ -26,6 +30,7 @@ go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6
|
|||||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
|
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
|
|||||||
Reference in New Issue
Block a user