First stab at maglevc

This commit is contained in:
2026-04-11 02:48:00 +02:00
parent d8ad89d115
commit 46e78ec36f
8 changed files with 700 additions and 3 deletions

307
cmd/maglevc/commands.go Normal file
View 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
View 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
View 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
View 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
View 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
}