v1.0.0 — first release
Bump VERSION to 1.0.0 and cut the first tagged release of vpp-maglev. Also in this commit: - maglevc: MAGLEV_SERVER env var as an alternative to the --server flag, matching the MAGLEV_CONFIG / MAGLEV_GRPC_ADDR convention on the other binaries. The flag takes precedence when both are set. - Rename cmd/maglevd -> cmd/server and cmd/maglevc -> cmd/client so the source directory names are decoupled from binary names (the frontend and tester commands already followed this convention). Build outputs and the Debian packages are unchanged.
This commit is contained in:
52
cmd/client/color.go
Normal file
52
cmd/client/color.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2026, Pim van Pelt <pim@ipng.ch>
|
||||
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
ansiBlue = "\x1b[34m"
|
||||
ansiRed = "\x1b[31m"
|
||||
ansiReset = "\x1b[0m"
|
||||
)
|
||||
|
||||
// colorEnabled is set by the -color flag in main.
|
||||
var colorEnabled bool
|
||||
|
||||
// label wraps s in dark-blue ANSI when color output is enabled.
|
||||
//
|
||||
// Tabwriter caveat: tabwriter.Writer counts *bytes* per cell, not
|
||||
// rendered columns. ANSI escape codes (`\x1b[34m…\x1b[0m`, 11 bytes)
|
||||
// inflate a cell's apparent width without affecting what the terminal
|
||||
// draws. Two things follow:
|
||||
//
|
||||
// 1. Key-value layouts where column 1 is *always* labelled and
|
||||
// column 2 is *always* plain (e.g. `show vpp info`) stay aligned,
|
||||
// because every row adds the same 11 bytes to column 1.
|
||||
// 2. Multi-column tables where only the *header* row is labelled
|
||||
// drift: the header cells each carry 11 extra bytes that the data
|
||||
// rows don't, so data cells get over-padded. In those tables,
|
||||
// leave the header plain (see runShowVPPLBCounters) and only use
|
||||
// label() for labels that appear uniformly column-wise.
|
||||
func label(s string) string {
|
||||
if !colorEnabled {
|
||||
return s
|
||||
}
|
||||
return ansiBlue + s + ansiReset
|
||||
}
|
||||
|
||||
// formatError returns a user-friendly error string. gRPC status errors are
|
||||
// unwrapped to show only the server's message (no "rpc error: code = ..."
|
||||
// boilerplate). The result is wrapped in red ANSI when color is enabled.
|
||||
func formatError(err error) string {
|
||||
msg := err.Error()
|
||||
// google.golang.org/grpc/status errors format as:
|
||||
// rpc error: code = <Code> desc = <message>
|
||||
if i := strings.Index(msg, " desc = "); i >= 0 {
|
||||
msg = msg[i+len(" desc = "):]
|
||||
}
|
||||
if colorEnabled {
|
||||
return ansiRed + msg + ansiReset
|
||||
}
|
||||
return msg
|
||||
}
|
||||
806
cmd/client/commands.go
Normal file
806
cmd/client/commands.go
Normal file
@@ -0,0 +1,806 @@
|
||||
// Copyright (c) 2026, Pim van Pelt <pim@ipng.ch>
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
buildinfo "git.ipng.ch/ipng/vpp-maglev/cmd"
|
||||
"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 version
|
||||
showVersion := &Node{Word: "version", Help: "Show build version", Run: runShowVersion}
|
||||
|
||||
// show frontends [<name>] — without name: list all, with name: show details
|
||||
showFrontendName := &Node{
|
||||
Word: "<name>",
|
||||
Help: "Show details for a single frontend",
|
||||
Dynamic: dynFrontends,
|
||||
Run: runShowFrontend,
|
||||
}
|
||||
showFrontends := &Node{
|
||||
Word: "frontends",
|
||||
Help: "List all frontends",
|
||||
Run: runShowFrontends,
|
||||
Children: []*Node{showFrontendName},
|
||||
}
|
||||
|
||||
// show backends [<name>] — without name: list all, with name: show details
|
||||
showBackendName := &Node{
|
||||
Word: "<name>",
|
||||
Help: "Show details for a single backend",
|
||||
Dynamic: dynBackends,
|
||||
Run: runShowBackend,
|
||||
}
|
||||
showBackends := &Node{
|
||||
Word: "backends",
|
||||
Help: "List all backends",
|
||||
Run: runShowBackends,
|
||||
Children: []*Node{showBackendName},
|
||||
}
|
||||
|
||||
// show healthchecks [<name>] — without name: list all, with name: show details
|
||||
showHealthCheckName := &Node{
|
||||
Word: "<name>",
|
||||
Help: "Show details for a single health check",
|
||||
Dynamic: dynHealthChecks,
|
||||
Run: runShowHealthCheck,
|
||||
}
|
||||
showHealthChecks := &Node{
|
||||
Word: "healthchecks",
|
||||
Help: "List all health checks",
|
||||
Run: runShowHealthChecks,
|
||||
Children: []*Node{showHealthCheckName},
|
||||
}
|
||||
|
||||
// show vpp info / lb state / lb counters
|
||||
showVPPInfo := &Node{Word: "info", Help: "Show VPP version, uptime, and connection status", Run: runShowVPPInfo}
|
||||
showVPPLBState := &Node{Word: "state", Help: "Show VPP load-balancer state (VIPs and application servers)", Run: runShowVPPLBState}
|
||||
showVPPLBCounters := &Node{Word: "counters", Help: "Show VPP per-VIP and per-backend packet/byte counters (refreshed every ~5s server-side)", Run: runShowVPPLBCounters}
|
||||
showVPPLB := &Node{
|
||||
Word: "lb",
|
||||
Help: "VPP load-balancer information",
|
||||
Children: []*Node{showVPPLBState, showVPPLBCounters},
|
||||
}
|
||||
showVPP := &Node{
|
||||
Word: "vpp",
|
||||
Help: "VPP dataplane information",
|
||||
Children: []*Node{showVPPInfo, showVPPLB},
|
||||
}
|
||||
|
||||
show.Children = []*Node{
|
||||
showVersion,
|
||||
showFrontends,
|
||||
showBackends,
|
||||
showHealthChecks,
|
||||
showVPP,
|
||||
}
|
||||
|
||||
// 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: "disable", Help: "disable backend (stop probing, remove from rotation)", Run: runDisableBackend}
|
||||
setEnabled := &Node{Word: "enable", Help: "enable backend (resume probing)", Run: runEnableBackend}
|
||||
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 frontend <name> pool <pool> backend <name> weight <0-100> [flush]
|
||||
//
|
||||
// The tree walker only puts tokens from slot (Dynamic) nodes into
|
||||
// args, so the literal "flush" keyword isn't visible in the arg
|
||||
// list. We use two distinct Run functions to distinguish the two
|
||||
// leaf paths instead — both share the same underlying helper.
|
||||
setWeightFlush := &Node{
|
||||
Word: "flush",
|
||||
Help: "also drop VPP's flow table for this backend (otherwise only the new-buckets map is updated)",
|
||||
Run: runSetFrontendPoolBackendWeightFlush,
|
||||
}
|
||||
setWeightValue := &Node{
|
||||
Word: "<weight>",
|
||||
Help: "Set weight of a backend in a pool (0-100)",
|
||||
Dynamic: dynNone, // accepts any integer; no tab-completion candidates
|
||||
Run: runSetFrontendPoolBackendWeight,
|
||||
Children: []*Node{setWeightFlush},
|
||||
}
|
||||
setFrontendPoolBackendWeight := &Node{Word: "weight", Help: "set backend weight in pool", Children: []*Node{setWeightValue}}
|
||||
setFrontendPoolBackendName := &Node{
|
||||
Word: "<backend>",
|
||||
Help: "backend name",
|
||||
Dynamic: dynBackends,
|
||||
Children: []*Node{setFrontendPoolBackendWeight},
|
||||
}
|
||||
setFrontendPoolBackend := &Node{Word: "backend", Help: "select a backend", Children: []*Node{setFrontendPoolBackendName}}
|
||||
setFrontendPoolName := &Node{
|
||||
Word: "<pool>",
|
||||
Help: "pool name",
|
||||
Dynamic: dynNone, // pool names aren't listed via gRPC; accepts any input
|
||||
Children: []*Node{setFrontendPoolBackend},
|
||||
}
|
||||
setFrontendPool := &Node{Word: "pool", Help: "select a pool", Children: []*Node{setFrontendPoolName}}
|
||||
setFrontendName := &Node{
|
||||
Word: "<name>",
|
||||
Help: "frontend name",
|
||||
Dynamic: dynFrontends,
|
||||
Children: []*Node{setFrontendPool},
|
||||
}
|
||||
setFrontend := &Node{
|
||||
Word: "frontend",
|
||||
Help: "modify a frontend",
|
||||
Children: []*Node{setFrontendName},
|
||||
}
|
||||
|
||||
set.Children = []*Node{setBackend, setFrontend}
|
||||
|
||||
// watch events [num <n>] [log [level <level>]] [backend] [frontend]
|
||||
//
|
||||
// All tokens after 'events' are captured as args via a self-referencing slot
|
||||
// node. This lets runWatchEvents parse the optional flags manually while still
|
||||
// providing tab-completion through the dynamic enumerator.
|
||||
watchEventsOptSlot := &Node{
|
||||
Word: "<opt>",
|
||||
Help: "Stream events with options",
|
||||
Dynamic: dynWatchEventOpts,
|
||||
Run: runWatchEvents,
|
||||
}
|
||||
watchEventsOptSlot.Children = []*Node{watchEventsOptSlot}
|
||||
|
||||
watchEvents := &Node{
|
||||
Word: "events",
|
||||
Help: "stream events (press any key or Ctrl-C to stop)",
|
||||
Run: runWatchEvents,
|
||||
Children: []*Node{watchEventsOptSlot},
|
||||
}
|
||||
watch := &Node{
|
||||
Word: "watch",
|
||||
Help: "watch live event streams",
|
||||
Children: []*Node{watchEvents},
|
||||
}
|
||||
|
||||
// config check / reload
|
||||
configCheck := &Node{Word: "check", Help: "Check configuration file", Run: runConfigCheck}
|
||||
configReload := &Node{Word: "reload", Help: "Check and reload configuration", Run: runConfigReload}
|
||||
configNode := &Node{
|
||||
Word: "config",
|
||||
Help: "configuration commands",
|
||||
Children: []*Node{configCheck, configReload},
|
||||
}
|
||||
|
||||
// sync vpp lb state [<name>]
|
||||
//
|
||||
// Without a name: run SyncLBStateAll (may remove stale VIPs).
|
||||
// With a name: run SyncLBStateVIP(name) for just that frontend (no removals).
|
||||
syncVPPLBStateName := &Node{
|
||||
Word: "<name>",
|
||||
Help: "Sync a single frontend's VIP to VPP",
|
||||
Dynamic: dynFrontends,
|
||||
Run: runSyncVPPLBState,
|
||||
}
|
||||
syncVPPLBState := &Node{
|
||||
Word: "state",
|
||||
Help: "Sync the VPP load-balancer dataplane from the running config",
|
||||
Run: runSyncVPPLBState,
|
||||
Children: []*Node{syncVPPLBStateName},
|
||||
}
|
||||
syncVPPLB := &Node{
|
||||
Word: "lb",
|
||||
Help: "VPP load-balancer sync commands",
|
||||
Children: []*Node{syncVPPLBState},
|
||||
}
|
||||
syncVPP := &Node{
|
||||
Word: "vpp",
|
||||
Help: "VPP dataplane sync commands",
|
||||
Children: []*Node{syncVPPLB},
|
||||
}
|
||||
syncNode := &Node{
|
||||
Word: "sync",
|
||||
Help: "Reconcile dataplane state from the running config",
|
||||
Children: []*Node{syncVPP},
|
||||
}
|
||||
|
||||
root.Children = []*Node{show, set, watch, configNode, syncNode, 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
|
||||
}
|
||||
|
||||
// dynNone marks a slot node that accepts any input but provides no
|
||||
// tab-completion candidates (e.g. a pool name or numeric weight value).
|
||||
func dynNone(_ context.Context, _ grpcapi.MaglevClient) []string { return nil }
|
||||
|
||||
// ---- run functions ---------------------------------------------------------
|
||||
|
||||
func runShowVPPInfo(ctx context.Context, client grpcapi.MaglevClient, _ []string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
info, err := client.GetVPPInfo(ctx, &grpcapi.GetVPPInfoRequest{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("version"), info.Version)
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("build-date"), info.BuildDate)
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("build-dir"), info.BuildDirectory)
|
||||
_, _ = fmt.Fprintf(w, "%s\t%d\n", label("vpp-pid"), info.Pid)
|
||||
if info.BoottimeNs > 0 {
|
||||
bootTime := time.Unix(0, info.BoottimeNs)
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s (%s)\n", label("vpp-boottime"),
|
||||
bootTime.Format("2006-01-02 15:04:05"),
|
||||
formatDuration(time.Since(bootTime)))
|
||||
}
|
||||
connTime := time.Unix(0, info.ConnecttimeNs)
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s (%s)\n", label("connected"),
|
||||
connTime.Format("2006-01-02 15:04:05"),
|
||||
formatDuration(time.Since(connTime)))
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
func runShowVPPLBState(ctx context.Context, client grpcapi.MaglevClient, _ []string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
state, err := client.GetVPPLBState(ctx, &grpcapi.GetVPPLBStateRequest{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- global config ----
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
_, _ = fmt.Fprintf(w, "%s\n", label("global"))
|
||||
if state.Conf.Ip4SrcAddress != "" {
|
||||
_, _ = fmt.Fprintf(w, " %s\t%s\n", label("ip4-src"), state.Conf.Ip4SrcAddress)
|
||||
}
|
||||
if state.Conf.Ip6SrcAddress != "" {
|
||||
_, _ = fmt.Fprintf(w, " %s\t%s\n", label("ip6-src"), state.Conf.Ip6SrcAddress)
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, " %s\t%d\n", label("sticky-buckets-per-core"), state.Conf.StickyBucketsPerCore)
|
||||
_, _ = fmt.Fprintf(w, " %s\t%ds\n", label("flow-timeout"), state.Conf.FlowTimeout)
|
||||
if err := w.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(state.Vips) == 0 {
|
||||
fmt.Println(label("vips") + " (none)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- per-VIP details ----
|
||||
for _, v := range state.Vips {
|
||||
fmt.Println()
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("vip"), stripHostMask(v.Prefix))
|
||||
_, _ = fmt.Fprintf(w, " %s\t%s\n", label("protocol"), protoString(v.Protocol))
|
||||
_, _ = fmt.Fprintf(w, " %s\t%d\n", label("port"), v.Port)
|
||||
_, _ = fmt.Fprintf(w, " %s\t%s\n", label("encap"), v.Encap)
|
||||
_, _ = fmt.Fprintf(w, " %s\t%t\n", label("src-ip-sticky"), v.SrcIpSticky)
|
||||
_, _ = fmt.Fprintf(w, " %s\t%d\n", label("flow-table-length"), v.FlowTableLength)
|
||||
_, _ = fmt.Fprintf(w, " %s\t%d\n", label("application-servers"), len(v.ApplicationServers))
|
||||
if err := w.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, a := range v.ApplicationServers {
|
||||
fmt.Printf(" %s %s %s %d %s %d\n",
|
||||
label("address"), a.Address,
|
||||
label("weight"), a.Weight,
|
||||
label("flow-table-buckets"), a.NumBuckets)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runShowVPPLBCounters prints the per-VIP runtime counters captured by
|
||||
// maglevd's 5s scrape loop. Values are up to 5 seconds stale; Prometheus
|
||||
// is the right tool if you need live rates. There is no per-backend
|
||||
// block — see internal/vpp/lbstats.go::scrapeLBStats for why VPP's LB
|
||||
// plugin doesn't expose per-backend packet counters today.
|
||||
func runShowVPPLBCounters(ctx context.Context, client grpcapi.MaglevClient, _ []string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
resp, err := client.GetVPPLBCounters(ctx, &grpcapi.GetVPPLBCountersRequest{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(resp.Vips) == 0 {
|
||||
fmt.Println("(no counters — VPP disconnected or scrape pending)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- frontend-counters ----
|
||||
//
|
||||
// Column headers are plain strings, not label()-wrapped. tabwriter
|
||||
// counts bytes (not rendered width), so wrapping a header cell in
|
||||
// ANSI escape codes inflates its apparent width by ~11 bytes and
|
||||
// the data rows below — which are plain numeric strings — end up
|
||||
// over-padded. The label() convention only works when every cell
|
||||
// in a column shares the same wrapping, which the key-value show
|
||||
// commands do but this table can't (we're not about to colourise
|
||||
// every packet count).
|
||||
fmt.Println(label("frontend-counters"))
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
_, _ = fmt.Fprintf(w, " vip\tproto\tport\tfirst\tnext\tuntracked\tno-server\tfib-packets\tfib-bytes\n")
|
||||
for _, v := range resp.Vips {
|
||||
_, _ = fmt.Fprintf(w, " %s\t%s\t%d\t%d\t%d\t%d\t%d\t%d\t%d\n",
|
||||
stripHostMask(v.Prefix), v.Protocol, v.Port,
|
||||
v.FirstPacket, v.NextPacket,
|
||||
v.UntrackedPacket, v.NoServer,
|
||||
v.Packets, v.Bytes,
|
||||
)
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
// stripHostMask trims "/32" (IPv4) or "/128" (IPv6) from a VIP's CIDR
|
||||
// string. maglevd only programs host-prefix VIPs so the mask is always
|
||||
// one of these two values and carries no information for a human reader.
|
||||
// Non-host prefixes and unparseable strings are returned unchanged so
|
||||
// future changes don't silently lose data.
|
||||
func stripHostMask(prefix string) string {
|
||||
if strings.HasSuffix(prefix, "/32") || strings.HasSuffix(prefix, "/128") {
|
||||
return prefix[:strings.LastIndexByte(prefix, '/')]
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
|
||||
// protoString renders an IP protocol number as a name (tcp, udp, any, or numeric).
|
||||
func protoString(p uint32) string {
|
||||
switch p {
|
||||
case 6:
|
||||
return "tcp"
|
||||
case 17:
|
||||
return "udp"
|
||||
case 255:
|
||||
return "any"
|
||||
}
|
||||
return fmt.Sprintf("%d", p)
|
||||
}
|
||||
|
||||
func runSyncVPPLBState(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
req := &grpcapi.SyncVPPLBStateRequest{}
|
||||
if len(args) > 0 && args[0] != "" {
|
||||
name := args[0]
|
||||
req.FrontendName = &name
|
||||
}
|
||||
if _, err := client.SyncVPPLBState(ctx, req); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.FrontendName != nil {
|
||||
fmt.Printf("synced frontend %q to VPP\n", *req.FrontendName)
|
||||
} else {
|
||||
fmt.Println("synced full LB state to VPP")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runShowVersion(_ context.Context, _ grpcapi.MaglevClient, _ []string) error {
|
||||
fmt.Printf("maglevc %s (commit %s, built %s)\n",
|
||||
buildinfo.Version(), buildinfo.Commit(), buildinfo.Date())
|
||||
return nil
|
||||
}
|
||||
|
||||
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, "%s\t%s\n", label("name"), info.Name)
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("address"), info.Address)
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("protocol"), info.Protocol)
|
||||
_, _ = fmt.Fprintf(w, "%s\t%d\n", label("port"), info.Port)
|
||||
_, _ = fmt.Fprintf(w, "%s\t%t\n", label("src-ip-sticky"), info.SrcIpSticky)
|
||||
_, _ = fmt.Fprintf(w, "%s\t%t\n", label("flush-on-down"), info.FlushOnDown)
|
||||
if info.Description != "" {
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("description"), info.Description)
|
||||
}
|
||||
if len(info.Pools) > 0 {
|
||||
_, _ = fmt.Fprintf(w, "%s\n", label("pools"))
|
||||
}
|
||||
if err := w.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Pool section uses direct Printf with fixed-width padding so that ANSI
|
||||
// escape codes in labels don't confuse tabwriter's byte-based alignment.
|
||||
// "backends" is always the widest pool label (8 chars); all pool labels
|
||||
// are right-padded to that width, giving a 2+8+2 = 12-char visual indent.
|
||||
const poolLblWidth = len("backends")
|
||||
const poolIndent = " "
|
||||
const poolSep = " "
|
||||
contIndent := strings.Repeat(" ", len(poolIndent)+poolLblWidth+len(poolSep))
|
||||
|
||||
for _, pool := range info.Pools {
|
||||
namePad := strings.Repeat(" ", poolLblWidth-len("name"))
|
||||
fmt.Printf("%s%s%s%s%s\n", poolIndent, label("name"), namePad, poolSep, pool.Name)
|
||||
for i, pb := range pool.Backends {
|
||||
beInfo, beErr := client.GetBackend(ctx, &grpcapi.GetBackendRequest{Name: pb.Name})
|
||||
suffix := ""
|
||||
if beErr == nil && !beInfo.Enabled {
|
||||
suffix = " [disabled]"
|
||||
}
|
||||
// Show both the configured weight (from YAML) and the
|
||||
// state-aware effective weight (what gets programmed into VPP
|
||||
// after pool-failover logic). Format matches the VPP-style
|
||||
// key-value line so robot tests can parse it with a regex.
|
||||
metaStr := fmt.Sprintf(" %s %d %s %d",
|
||||
label("weight"), pb.Weight,
|
||||
label("effective"), pb.EffectiveWeight)
|
||||
if i == 0 {
|
||||
bePad := strings.Repeat(" ", poolLblWidth-len("backends"))
|
||||
fmt.Printf("%s%s%s%s%s%s%s\n", poolIndent, label("backends"), bePad, poolSep, pb.Name, metaStr, suffix)
|
||||
} else {
|
||||
fmt.Printf("%s%s%s%s\n", contIndent, pb.Name, metaStr, suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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, "%s\t%s\n", label("name"), info.Name)
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("address"), info.Address)
|
||||
stateDur := ""
|
||||
if len(info.Transitions) > 0 {
|
||||
since := time.Since(time.Unix(0, info.Transitions[0].AtUnixNs))
|
||||
stateDur = " for " + formatDuration(since)
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s%s\n", label("state"), info.State, stateDur)
|
||||
_, _ = fmt.Fprintf(w, "%s\t%v\n", label("enabled"), info.Enabled)
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("healthcheck"), info.Healthcheck)
|
||||
for i, t := range info.Transitions {
|
||||
ts := time.Unix(0, t.AtUnixNs)
|
||||
var lbl string
|
||||
if i == 0 {
|
||||
lbl = label("transitions")
|
||||
} else {
|
||||
// Pad to same visible width as "transitions" and wrap through
|
||||
// label() so tabwriter sees the same byte count (ANSI overhead
|
||||
// is identical on every row, keeping columns aligned).
|
||||
lbl = label(" ")
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s → %s\t%s\t%s\n",
|
||||
lbl,
|
||||
t.From, t.To,
|
||||
ts.Format("2006-01-02 15:04:05.000"),
|
||||
formatAgo(time.Since(ts)),
|
||||
)
|
||||
}
|
||||
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, "%s\t%s\n", label("name"), info.Name)
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("type"), info.Type)
|
||||
if info.Port > 0 {
|
||||
_, _ = fmt.Fprintf(w, "%s\t%d\n", label("port"), info.Port)
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("interval"), time.Duration(info.IntervalNs))
|
||||
if info.FastIntervalNs > 0 {
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("fast-interval"), time.Duration(info.FastIntervalNs))
|
||||
}
|
||||
if info.DownIntervalNs > 0 {
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("down-interval"), time.Duration(info.DownIntervalNs))
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("timeout"), time.Duration(info.TimeoutNs))
|
||||
_, _ = fmt.Fprintf(w, "%s\t%d\n", label("rise"), info.Rise)
|
||||
_, _ = fmt.Fprintf(w, "%s\t%d\n", label("fall"), info.Fall)
|
||||
if info.ProbeIpv4Src != "" {
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("probe-ipv4-src"), info.ProbeIpv4Src)
|
||||
}
|
||||
if info.ProbeIpv6Src != "" {
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("probe-ipv6-src"), info.ProbeIpv6Src)
|
||||
}
|
||||
if h := info.Http; h != nil {
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("http.path"), h.Path)
|
||||
if h.Host != "" {
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("http.host"), h.Host)
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, "%s\t%d-%d\n", label("http.response-code"), h.ResponseCodeMin, h.ResponseCodeMax)
|
||||
if h.ResponseRegexp != "" {
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("http.response-regexp"), h.ResponseRegexp)
|
||||
}
|
||||
}
|
||||
if t := info.Tcp; t != nil {
|
||||
_, _ = fmt.Fprintf(w, "%s\t%v\n", label("tcp.ssl"), t.Ssl)
|
||||
if t.ServerName != "" {
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("tcp.server-name"), 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.BackendRequest{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.BackendRequest{Name: args[0]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s: setting state to '%s'\n", info.Name, info.State)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSetFrontendPoolBackendWeight(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
|
||||
return setFrontendPoolBackendWeight(ctx, client, args, false)
|
||||
}
|
||||
|
||||
func runSetFrontendPoolBackendWeightFlush(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
|
||||
return setFrontendPoolBackendWeight(ctx, client, args, true)
|
||||
}
|
||||
|
||||
func setFrontendPoolBackendWeight(ctx context.Context, client grpcapi.MaglevClient, args []string, flush bool) error {
|
||||
if len(args) != 4 {
|
||||
return fmt.Errorf("usage: set frontend <name> pool <pool> backend <name> weight <0-100> [flush]")
|
||||
}
|
||||
frontendName, poolName, backendName, weightStr := args[0], args[1], args[2], args[3]
|
||||
weight, err := strconv.Atoi(weightStr)
|
||||
if err != nil || weight < 0 || weight > 100 {
|
||||
return fmt.Errorf("weight: expected integer 0-100, got %q", weightStr)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
info, err := client.SetFrontendPoolBackendWeight(ctx, &grpcapi.SetWeightRequest{
|
||||
Frontend: frontendName,
|
||||
Pool: poolName,
|
||||
Backend: backendName,
|
||||
Weight: int32(weight),
|
||||
Flush: flush,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Print the updated pool so the user can confirm the new weight.
|
||||
for _, pool := range info.Pools {
|
||||
if pool.Name != poolName {
|
||||
continue
|
||||
}
|
||||
for _, pb := range pool.Backends {
|
||||
if pb.Name == backendName {
|
||||
flushNote := ""
|
||||
if flush {
|
||||
flushNote = " (flushed)"
|
||||
}
|
||||
fmt.Printf("%s pool %s backend %s: weight set to %d%s\n",
|
||||
info.Name, pool.Name, pb.Name, pb.Weight, flushNote)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runEnableBackend(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: set backend <name> enable")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
info, err := client.EnableBackend(ctx, &grpcapi.BackendRequest{Name: args[0]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s: enabled, state is '%s'\n", info.Name, info.State)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDisableBackend(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: set backend <name> disable")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
info, err := client.DisableBackend(ctx, &grpcapi.BackendRequest{Name: args[0]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s: disabled, state is '%s'\n", info.Name, info.State)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runConfigCheck(ctx context.Context, client grpcapi.MaglevClient, _ []string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
resp, err := client.CheckConfig(ctx, &grpcapi.CheckConfigRequest{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Ok {
|
||||
fmt.Println("config ok")
|
||||
return nil
|
||||
}
|
||||
if resp.ParseError != "" {
|
||||
return fmt.Errorf("parse error: %s", resp.ParseError)
|
||||
}
|
||||
return fmt.Errorf("semantic error: %s", resp.SemanticError)
|
||||
}
|
||||
|
||||
func runConfigReload(ctx context.Context, client grpcapi.MaglevClient, _ []string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
resp, err := client.ReloadConfig(ctx, &grpcapi.ReloadConfigRequest{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Ok {
|
||||
fmt.Println("config reloaded")
|
||||
return nil
|
||||
}
|
||||
if resp.ParseError != "" {
|
||||
return fmt.Errorf("parse error: %s", resp.ParseError)
|
||||
}
|
||||
if resp.SemanticError != "" {
|
||||
return fmt.Errorf("semantic error: %s", resp.SemanticError)
|
||||
}
|
||||
return fmt.Errorf("reload error: %s", resp.ReloadError)
|
||||
}
|
||||
|
||||
// formatDuration formats a duration as Xd Xh Xm Xs without milliseconds.
|
||||
func formatDuration(d time.Duration) string {
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
d = d.Truncate(time.Second)
|
||||
|
||||
days := int(d.Hours()) / 24
|
||||
d -= time.Duration(days) * 24 * time.Hour
|
||||
hours := int(d.Hours())
|
||||
d -= time.Duration(hours) * time.Hour
|
||||
minutes := int(d.Minutes())
|
||||
d -= time.Duration(minutes) * time.Minute
|
||||
seconds := int(d.Seconds())
|
||||
|
||||
var b strings.Builder
|
||||
if days > 0 {
|
||||
fmt.Fprintf(&b, "%dd", days)
|
||||
}
|
||||
if hours > 0 {
|
||||
fmt.Fprintf(&b, "%dh", hours)
|
||||
}
|
||||
if minutes > 0 {
|
||||
fmt.Fprintf(&b, "%dm", minutes)
|
||||
}
|
||||
if seconds > 0 || b.Len() == 0 {
|
||||
fmt.Fprintf(&b, "%ds", seconds)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func formatAgo(d time.Duration) string {
|
||||
return formatDuration(d) + " ago"
|
||||
}
|
||||
183
cmd/client/complete.go
Normal file
183
cmd/client/complete.go
Normal file
@@ -0,0 +1,183 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
// Strip the '?' that was just appended to line[:pos].
|
||||
before := string(line[:pos])
|
||||
if len(before) > 0 && before[len(before)-1] == '?' {
|
||||
before = before[:len(before)-1]
|
||||
}
|
||||
tokens := splitTokens(before)
|
||||
|
||||
// Split into confirmed prefix tokens and the partial token being typed.
|
||||
var prefix []string
|
||||
var partial 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]
|
||||
}
|
||||
|
||||
// Walk the confirmed prefix to the current node, then try to advance one
|
||||
// more step using the partial token (via prefix-match or slot fallback).
|
||||
// This mirrors birdc: "sh?" expands "sh" to "show" and shows show's subtree.
|
||||
node, _, remaining := Walk(ql.root, prefix)
|
||||
displayPrefix := strings.Join(prefix, " ")
|
||||
var unknownMsg string
|
||||
if len(remaining) > 0 {
|
||||
// One of the confirmed prefix tokens was unknown. Show an
|
||||
// "unknown" banner, then list what's available at the deepest
|
||||
// node we *did* reach so the operator can see what they could
|
||||
// have typed instead. The partial at the cursor is irrelevant
|
||||
// once the left context is already broken — no downstream
|
||||
// branch reads it after we enter this branch, so we don't
|
||||
// bother clearing it.
|
||||
consumed := prefix[:len(prefix)-len(remaining)]
|
||||
bad := remaining[0]
|
||||
if len(consumed) == 0 {
|
||||
unknownMsg = fmt.Sprintf("unknown command: %s", bad)
|
||||
} else {
|
||||
unknownMsg = fmt.Sprintf("unknown subcommand %q after %q", bad, strings.Join(consumed, " "))
|
||||
}
|
||||
displayPrefix = strings.Join(consumed, " ")
|
||||
} else if partial != "" {
|
||||
if next := matchFixedChild(node.Children, partial); next != nil {
|
||||
// Partial uniquely matched a fixed child — descend into it.
|
||||
node = next
|
||||
displayPrefix = strings.Join(tokens, " ")
|
||||
} else if slot := findSlotChild(node.Children); slot != nil {
|
||||
// Partial is filling a slot node.
|
||||
node = slot
|
||||
displayPrefix = strings.Join(tokens, " ")
|
||||
}
|
||||
// If partial matched nothing (ambiguous or dead end), stay at the
|
||||
// current node and show its subcommands with the confirmed prefix.
|
||||
}
|
||||
|
||||
// Expand all leaf paths reachable from the current node.
|
||||
lines := expandPaths(node, displayPrefix, make(map[*Node]bool))
|
||||
|
||||
// If the cursor is at a position where the next input is a dynamic slot,
|
||||
// fetch live values now and show them below the syntax lines.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), completeTimeout)
|
||||
defer cancel()
|
||||
var dynValues []string
|
||||
var dynWord string
|
||||
if slot := findSlotChild(node.Children); slot != nil && slot.Dynamic != nil {
|
||||
dynValues = slot.Dynamic(ctx, ql.client)
|
||||
dynWord = slot.Word
|
||||
}
|
||||
|
||||
// Right-align the help column at the width of the longest path + 2.
|
||||
maxLen := 0
|
||||
for _, l := range lines {
|
||||
if len(l.path) > maxLen {
|
||||
maxLen = len(l.path)
|
||||
}
|
||||
}
|
||||
|
||||
// Emit output. Raw terminal mode requires \r\n.
|
||||
//
|
||||
// readline's wrapWriter wraps every Write in a clean-write-print
|
||||
// cycle: it erases the current input line, runs our closure, and
|
||||
// redraws the prompt+buffer afterwards. That means starting the
|
||||
// output with a bare "\r\n" leaves the original row blank, so the
|
||||
// operator loses sight of what they typed. Instead we echo the
|
||||
// full "maglev> show vpp lb ?" ourselves as the first write —
|
||||
// that lands on the just-cleaned row, birdc-style, and the
|
||||
// subsequent Fprintfs each redraw a fresh prompt below the help.
|
||||
_, _ = fmt.Fprintf(ql.rl.Stderr(), "%s%s\r\n", ql.rl.Config.Prompt, string(line))
|
||||
if unknownMsg != "" {
|
||||
_, _ = fmt.Fprintf(ql.rl.Stderr(), " %s\r\n", unknownMsg)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
_, _ = fmt.Fprintf(ql.rl.Stderr(), " <no completions>\r\n")
|
||||
} else {
|
||||
for _, l := range lines {
|
||||
if l.help != "" {
|
||||
_, _ = fmt.Fprintf(ql.rl.Stderr(), "%-*s %s\r\n", maxLen+2, l.path, l.help)
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(ql.rl.Stderr(), "%s\r\n", l.path)
|
||||
}
|
||||
}
|
||||
if len(dynValues) > 0 {
|
||||
_, _ = fmt.Fprintf(ql.rl.Stderr(), " %s: %s\r\n", dynWord, strings.Join(dynValues, " "))
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the '?' from the line and step cursor back one position.
|
||||
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)
|
||||
}
|
||||
86
cmd/client/main.go
Normal file
86
cmd/client/main.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// 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"
|
||||
|
||||
buildinfo "git.ipng.ch/ipng/vpp-maglev/cmd"
|
||||
"git.ipng.ch/ipng/vpp-maglev/internal/grpcapi"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", formatError(err))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
defaultServer := "localhost:9090"
|
||||
if v := os.Getenv("MAGLEV_SERVER"); v != "" {
|
||||
defaultServer = v
|
||||
}
|
||||
serverAddr := flag.String("server", defaultServer, "maglev server address (env: MAGLEV_SERVER)")
|
||||
color := flag.Bool("color", true, "colorize static labels in output (defaults to false in one-shot mode)")
|
||||
printVersion := flag.Bool("version", false, "print version and exit")
|
||||
flag.Parse()
|
||||
|
||||
if *printVersion {
|
||||
fmt.Printf("maglevc %s (commit %s, built %s)\n",
|
||||
buildinfo.Version(), buildinfo.Commit(), buildinfo.Date())
|
||||
return nil
|
||||
}
|
||||
|
||||
// Detect whether -color was explicitly set so we can pick a
|
||||
// mode-aware default: color is useful in the interactive shell but
|
||||
// noise (ANSI escapes) when piping one-shot output into scripts.
|
||||
colorExplicit := false
|
||||
flag.Visit(func(f *flag.Flag) {
|
||||
if f.Name == "color" {
|
||||
colorExplicit = true
|
||||
}
|
||||
})
|
||||
|
||||
conn, err := grpc.NewClient(*serverAddr,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect %s: %w", *serverAddr, err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
client := grpcapi.NewMaglevClient(conn)
|
||||
ctx := context.Background()
|
||||
|
||||
args := flag.Args()
|
||||
if len(args) == 0 {
|
||||
// Interactive shell: color defaults to true.
|
||||
if colorExplicit {
|
||||
colorEnabled = *color
|
||||
} else {
|
||||
colorEnabled = true
|
||||
}
|
||||
fmt.Printf("maglevc %s (commit %s, built %s)\n",
|
||||
buildinfo.Version(), buildinfo.Commit(), buildinfo.Date())
|
||||
return runShell(ctx, client)
|
||||
}
|
||||
|
||||
// One-shot command from CLI arguments: color defaults to false so
|
||||
// output is script-safe. Operators wanting color can still pass
|
||||
// -color=true explicitly.
|
||||
if colorExplicit {
|
||||
colorEnabled = *color
|
||||
} else {
|
||||
colorEnabled = false
|
||||
}
|
||||
root := buildTree()
|
||||
tokens := splitTokens(strings.Join(args, " "))
|
||||
return dispatch(ctx, root, client, tokens)
|
||||
}
|
||||
123
cmd/client/shell.go
Normal file
123
cmd/client/shell.go
Normal file
@@ -0,0 +1,123 @@
|
||||
// Copyright (c) 2026, Pim van Pelt <pim@ipng.ch>
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"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 func() { _ = 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(), "%s\n", formatError(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, remaining := Walk(root, tokens)
|
||||
|
||||
if len(remaining) > 0 {
|
||||
// One or more tokens couldn't be matched. Report the first
|
||||
// offending token with the consumed prefix for context; don't
|
||||
// dump the full command tree prefixed with garbage, which is
|
||||
// what the previous code did and what prompted this fix.
|
||||
consumed := tokens[:len(tokens)-len(remaining)]
|
||||
return unknownCommandError(consumed, remaining[0])
|
||||
}
|
||||
|
||||
if node.Run == nil {
|
||||
showHelpAt(node, strings.Join(tokens, " "))
|
||||
return nil
|
||||
}
|
||||
|
||||
return node.Run(ctx, client, args)
|
||||
}
|
||||
|
||||
// unknownCommandError builds the error returned by dispatch when the
|
||||
// tree walk couldn't consume the full token list. The format differs
|
||||
// slightly depending on whether any tokens were consumed, so the
|
||||
// message always points at the first unknown token and its context.
|
||||
func unknownCommandError(consumed []string, bad string) error {
|
||||
if len(consumed) == 0 {
|
||||
return fmt.Errorf("unknown command: %s", bad)
|
||||
}
|
||||
return fmt.Errorf("unknown subcommand %q after %q", bad, strings.Join(consumed, " "))
|
||||
}
|
||||
|
||||
// showHelpAt prints the reachable leaves below node, each displayed
|
||||
// with the given prefix. Split from dispatch so the caller can decide
|
||||
// which node to anchor the help at without re-walking the tree.
|
||||
func showHelpAt(node *Node, prefix string) {
|
||||
lines := expandPaths(node, prefix, make(map[*Node]bool))
|
||||
|
||||
maxLen := 0
|
||||
for _, l := range lines {
|
||||
if len(l.path) > maxLen {
|
||||
maxLen = len(l.path)
|
||||
}
|
||||
}
|
||||
|
||||
if len(lines) == 0 {
|
||||
fmt.Println(" <no completions>")
|
||||
return
|
||||
}
|
||||
for _, l := range lines {
|
||||
if l.help != "" {
|
||||
fmt.Printf("%-*s %s\n", maxLen+2, l.path, l.help)
|
||||
} else {
|
||||
fmt.Printf("%s\n", l.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
168
cmd/client/tree.go
Normal file
168
cmd/client/tree.go
Normal file
@@ -0,0 +1,168 @@
|
||||
// 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, the args collected from slot nodes,
|
||||
// and any tokens that could not be matched. A non-empty remaining slice
|
||||
// means the input contained a token that neither matched a fixed child
|
||||
// at the current node nor fed into a slot — callers should treat that
|
||||
// as "unknown command" rather than silently anchoring help at the root.
|
||||
func Walk(root *Node, tokens []string) (*Node, []string, []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. The caller gets the still-unconsumed tail
|
||||
// in the third return value.
|
||||
break
|
||||
}
|
||||
return node, args, tokens
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// helpLine is a (path, help) pair used when displaying '?' output.
|
||||
type helpLine struct {
|
||||
path string
|
||||
help string
|
||||
}
|
||||
|
||||
// expandPaths returns all (path, help) pairs for every node reachable from
|
||||
// node that has a Run function. prefix is the display string accumulated so
|
||||
// far (e.g. "show frontend"). visited prevents infinite loops through
|
||||
// self-referencing slot nodes like watchEventsOptSlot.
|
||||
func expandPaths(node *Node, prefix string, visited map[*Node]bool) []helpLine {
|
||||
if visited[node] {
|
||||
return nil
|
||||
}
|
||||
visited[node] = true
|
||||
|
||||
var lines []helpLine
|
||||
if node.Run != nil {
|
||||
lines = append(lines, helpLine{path: prefix, help: node.Help})
|
||||
}
|
||||
for _, child := range node.Children {
|
||||
childPrefix := child.Word
|
||||
if prefix != "" {
|
||||
childPrefix = prefix + " " + child.Word
|
||||
}
|
||||
lines = append(lines, expandPaths(child, childPrefix, visited)...)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// 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. If any of them are unknown,
|
||||
// offer no completions at all — continuing to suggest children off
|
||||
// the partially-walked node would mislead the user into "completing"
|
||||
// an invalid command.
|
||||
node, _, remaining := Walk(root, tokens)
|
||||
if len(remaining) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
192
cmd/client/tree_test.go
Normal file
192
cmd/client/tree_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
// Copyright (c) 2026, Pim van Pelt <pim@ipng.ch>
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExpandPathsRoot(t *testing.T) {
|
||||
root := buildTree()
|
||||
lines := expandPaths(root, "", make(map[*Node]bool))
|
||||
|
||||
// Should include well-known leaf paths.
|
||||
want := []string{
|
||||
"show version",
|
||||
"show frontends",
|
||||
"show frontends <name>",
|
||||
"show backends",
|
||||
"show backends <name>",
|
||||
"show healthchecks",
|
||||
"show healthchecks <name>",
|
||||
"set backend <name> pause",
|
||||
"set backend <name> resume",
|
||||
"set backend <name> disable",
|
||||
"set backend <name> enable",
|
||||
"set frontend <name> pool <pool> backend <backend> weight <weight>",
|
||||
"set frontend <name> pool <pool> backend <backend> weight <weight> flush",
|
||||
"watch events",
|
||||
"watch events <opt>",
|
||||
"config check",
|
||||
"show vpp info",
|
||||
"show vpp lb state",
|
||||
"show vpp lb counters",
|
||||
"config reload",
|
||||
"sync vpp lb state",
|
||||
"sync vpp lb state <name>",
|
||||
"quit",
|
||||
"exit",
|
||||
}
|
||||
|
||||
paths := make(map[string]bool, len(lines))
|
||||
for _, l := range lines {
|
||||
paths[l.path] = true
|
||||
}
|
||||
|
||||
for _, w := range want {
|
||||
if !paths[w] {
|
||||
t.Errorf("expandPaths(root) missing %q", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandPathsShow(t *testing.T) {
|
||||
root := buildTree()
|
||||
showNode, _, _ := Walk(root, []string{"show"})
|
||||
lines := expandPaths(showNode, "show", make(map[*Node]bool))
|
||||
|
||||
for _, l := range lines {
|
||||
if !strings.HasPrefix(l.path, "show ") {
|
||||
t.Errorf("unexpected path %q: should start with 'show '", l.path)
|
||||
}
|
||||
if l.help == "" {
|
||||
t.Errorf("path %q has empty help", l.path)
|
||||
}
|
||||
}
|
||||
// version, frontends, frontends <name>, backends, backends <name>,
|
||||
// healthchecks, healthchecks <name>, vpp info, vpp lb state,
|
||||
// vpp lb counters = 10 lines
|
||||
if len(lines) != 10 {
|
||||
t.Errorf("expected exactly 10 show subcommands, got %d", len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandPathsNoCycles(t *testing.T) {
|
||||
root := buildTree()
|
||||
// watch events has a self-referencing slot; expandPaths must terminate.
|
||||
watchEvents, _, _ := Walk(root, []string{"watch", "events"})
|
||||
lines := expandPaths(watchEvents, "watch events", make(map[*Node]bool))
|
||||
|
||||
// Should produce exactly 2 lines: "watch events" and "watch events <opt>".
|
||||
if len(lines) != 2 {
|
||||
t.Errorf("watch events: expected 2 lines, got %d: %v", len(lines), lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandPathsSetBackendName(t *testing.T) {
|
||||
root := buildTree()
|
||||
// Walk to the name slot so displayPrefix carries the actual arg.
|
||||
node, _, _ := Walk(root, []string{"set", "backend", "mybackend"})
|
||||
lines := expandPaths(node, "set backend mybackend", make(map[*Node]bool))
|
||||
|
||||
want := []string{
|
||||
"set backend mybackend pause",
|
||||
"set backend mybackend resume",
|
||||
"set backend mybackend disable",
|
||||
"set backend mybackend enable",
|
||||
}
|
||||
if len(lines) != len(want) {
|
||||
t.Fatalf("expected %d lines, got %d: %v", len(want), len(lines), lines)
|
||||
}
|
||||
for i, w := range want {
|
||||
if lines[i].path != w {
|
||||
t.Errorf("line %d: got %q, want %q", i, lines[i].path, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrefixMatchCollapsedNouns(t *testing.T) {
|
||||
root := buildTree()
|
||||
|
||||
// "sh ba" → show backends (list all) via prefix matching.
|
||||
node, args, rem := Walk(root, []string{"sh", "ba"})
|
||||
if node.Run == nil {
|
||||
t.Fatal("'sh ba' did not reach a Run node")
|
||||
}
|
||||
if len(args) != 0 {
|
||||
t.Errorf("'sh ba' should have 0 args, got %v", args)
|
||||
}
|
||||
if len(rem) != 0 {
|
||||
t.Errorf("'sh ba' should fully consume tokens, got remaining %v", rem)
|
||||
}
|
||||
|
||||
// "sh ba nginx0" → show backends <name> (get specific) via slot.
|
||||
node, args, rem = Walk(root, []string{"sh", "ba", "nginx0"})
|
||||
if node.Run == nil {
|
||||
t.Fatal("'sh ba nginx0' did not reach a Run node")
|
||||
}
|
||||
if len(args) != 1 || args[0] != "nginx0" {
|
||||
t.Errorf("'sh ba nginx0' args: got %v, want [nginx0]", args)
|
||||
}
|
||||
if len(rem) != 0 {
|
||||
t.Errorf("'sh ba nginx0' should fully consume tokens, got remaining %v", rem)
|
||||
}
|
||||
|
||||
// "sh fr" → show frontends (list all).
|
||||
node, _, _ = Walk(root, []string{"sh", "fr"})
|
||||
if node.Run == nil {
|
||||
t.Fatal("'sh fr' did not reach a Run node")
|
||||
}
|
||||
|
||||
// "sh he icmp" → show healthchecks icmp (get specific).
|
||||
node, args, _ = Walk(root, []string{"sh", "he", "icmp"})
|
||||
if node.Run == nil {
|
||||
t.Fatal("'sh he icmp' did not reach a Run node")
|
||||
}
|
||||
if len(args) != 1 || args[0] != "icmp" {
|
||||
t.Errorf("'sh he icmp' args: got %v, want [icmp]", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkUnknownTokens(t *testing.T) {
|
||||
root := buildTree()
|
||||
|
||||
// A bare unknown word leaves every token unconsumed and anchors
|
||||
// the returned node at the root — callers must treat this as
|
||||
// "unknown command" rather than silently showing the whole tree.
|
||||
node, _, rem := Walk(root, []string{"foo"})
|
||||
if node != root {
|
||||
t.Errorf("'foo' should leave walk at root, got %q", node.Word)
|
||||
}
|
||||
if len(rem) != 1 || rem[0] != "foo" {
|
||||
t.Errorf("'foo' remaining: got %v, want [foo]", rem)
|
||||
}
|
||||
|
||||
// Partial consumption: "show" matches but "bogus" doesn't. The
|
||||
// returned remaining is the first unmatched token onwards so the
|
||||
// caller can point at exactly what was wrong.
|
||||
node, _, rem = Walk(root, []string{"show", "bogus", "tail"})
|
||||
if node.Word != "show" {
|
||||
t.Errorf("'show bogus tail' should stop at show, got %q", node.Word)
|
||||
}
|
||||
if len(rem) != 2 || rem[0] != "bogus" || rem[1] != "tail" {
|
||||
t.Errorf("'show bogus tail' remaining: got %v, want [bogus tail]", rem)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandPathsWeightSlotWalk(t *testing.T) {
|
||||
// Verify the weight command is fully walkable (fixes bug: setWeightValue
|
||||
// and setFrontendPoolName were non-slot nodes that couldn't capture tokens).
|
||||
root := buildTree()
|
||||
node, args, _ := Walk(root, []string{"set", "frontend", "web", "pool", "primary", "backend", "be0", "weight", "42"})
|
||||
if node.Run == nil {
|
||||
t.Fatal("Walk did not reach a Run node for full weight command")
|
||||
}
|
||||
if len(args) != 4 {
|
||||
t.Errorf("expected 4 args (name, pool, backend, weight), got %d: %v", len(args), args)
|
||||
}
|
||||
if args[3] != "42" {
|
||||
t.Errorf("args[3] (weight): got %q, want 42", args[3])
|
||||
}
|
||||
}
|
||||
174
cmd/client/watch.go
Normal file
174
cmd/client/watch.go
Normal file
@@ -0,0 +1,174 @@
|
||||
// Copyright (c) 2026, Pim van Pelt <pim@ipng.ch>
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
|
||||
"git.ipng.ch/ipng/vpp-maglev/internal/grpcapi"
|
||||
)
|
||||
|
||||
func dynWatchEventOpts(_ context.Context, _ grpcapi.MaglevClient) []string {
|
||||
return []string{"num", "log", "backend", "frontend"}
|
||||
}
|
||||
|
||||
// runWatchEvents implements 'watch events [num <n>] [log [level <level>]] [backend] [frontend]'.
|
||||
// All tokens after 'events' are captured as args by the circular slot node in the tree.
|
||||
// If none of log/backend/frontend are mentioned, all three default to true.
|
||||
func runWatchEvents(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
|
||||
var maxEvents int // 0 = unlimited
|
||||
var wantLog, wantBackend, wantFrontend bool
|
||||
logLevel := ""
|
||||
anyExplicit := false
|
||||
|
||||
for i := 0; i < len(args); {
|
||||
switch args[i] {
|
||||
case "num":
|
||||
if i+1 >= len(args) {
|
||||
return fmt.Errorf("num requires a count argument")
|
||||
}
|
||||
n, err := strconv.Atoi(args[i+1])
|
||||
if err != nil || n < 1 {
|
||||
return fmt.Errorf("num: invalid count %q", args[i+1])
|
||||
}
|
||||
maxEvents = n
|
||||
i += 2
|
||||
case "log":
|
||||
wantLog = true
|
||||
anyExplicit = true
|
||||
if i+1 < len(args) && args[i+1] == "level" {
|
||||
if i+2 >= len(args) {
|
||||
return fmt.Errorf("log level requires a level argument")
|
||||
}
|
||||
logLevel = args[i+2]
|
||||
i += 3
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
case "backend":
|
||||
wantBackend = true
|
||||
anyExplicit = true
|
||||
i++
|
||||
case "frontend":
|
||||
wantFrontend = true
|
||||
anyExplicit = true
|
||||
i++
|
||||
default:
|
||||
return fmt.Errorf("unknown watch option %q; expected: num, log, backend, frontend", args[i])
|
||||
}
|
||||
}
|
||||
|
||||
if !anyExplicit {
|
||||
wantLog, wantBackend, wantFrontend = true, true, true
|
||||
}
|
||||
|
||||
boolp := func(b bool) *bool { v := b; return &v }
|
||||
req := &grpcapi.WatchRequest{
|
||||
Log: boolp(wantLog),
|
||||
LogLevel: logLevel,
|
||||
Backend: boolp(wantBackend),
|
||||
Frontend: boolp(wantFrontend),
|
||||
}
|
||||
|
||||
// Cancel the stream on keypress or signal.
|
||||
watchCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
go watchStopOnKeypress(watchCtx, cancel)
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
defer signal.Stop(sigCh)
|
||||
go func() {
|
||||
select {
|
||||
case <-sigCh:
|
||||
cancel()
|
||||
case <-watchCtx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
stream, err := client.WatchEvents(watchCtx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
marshaler := protojson.MarshalOptions{}
|
||||
count := 0
|
||||
for {
|
||||
ev, err := stream.Recv()
|
||||
if err != nil {
|
||||
if watchCtx.Err() != nil {
|
||||
return nil // stopped by keypress or signal
|
||||
}
|
||||
if st, ok := status.FromError(err); ok && st.Code() == codes.Canceled {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
data, err := marshaler.Marshal(ev)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal event: %w", err)
|
||||
}
|
||||
fmt.Printf("%s\n", data)
|
||||
count++
|
||||
if maxEvents > 0 && count >= maxEvents {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// watchStopOnKeypress puts stdin into cbreak mode (when it is a terminal) and
|
||||
// calls cancel when any byte arrives. Cbreak mode disables canonical (line)
|
||||
// input so a single keypress is sufficient, while preserving output
|
||||
// post-processing (OPOST/ONLCR) so that fmt.Printf("\n") still produces the
|
||||
// expected carriage-return+newline on screen. Falls back gracefully when stdin
|
||||
// is not a tty. The goroutine exits when ctx is cancelled.
|
||||
func watchStopOnKeypress(ctx context.Context, cancel context.CancelFunc) {
|
||||
fd := int(os.Stdin.Fd())
|
||||
if old, err := stdinCbreak(fd); err == nil {
|
||||
defer unix.IoctlSetTermios(fd, unix.TCSETSF, old) //nolint:errcheck
|
||||
}
|
||||
|
||||
readDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(readDone)
|
||||
buf := make([]byte, 1)
|
||||
os.Stdin.Read(buf) //nolint:errcheck
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-readDone:
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
// stdinCbreak sets the terminal referred to by fd into cbreak mode: canonical
|
||||
// input and echo are disabled (so single keystrokes are immediately available)
|
||||
// but output post-processing is left untouched (so \n still maps to \r\n).
|
||||
// Returns the previous termios so the caller can restore it, or an error if fd
|
||||
// is not a terminal.
|
||||
func stdinCbreak(fd int) (*unix.Termios, error) {
|
||||
old, err := unix.IoctlGetTermios(fd, unix.TCGETS)
|
||||
if err != nil {
|
||||
return nil, err // not a terminal
|
||||
}
|
||||
t := *old
|
||||
t.Lflag &^= unix.ICANON | unix.ECHO | unix.ECHOE | unix.ECHOK | unix.ECHONL
|
||||
t.Cc[unix.VMIN] = 1
|
||||
t.Cc[unix.VTIME] = 0
|
||||
if err := unix.IoctlSetTermios(fd, unix.TCSETS, &t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return old, nil
|
||||
}
|
||||
Reference in New Issue
Block a user