From 76fbe2eee094dcb311d28b65ab58bdbdc2bd3b01 Mon Sep 17 00:00:00 2001 From: Pim van Pelt Date: Fri, 5 Jun 2026 22:38:34 +0200 Subject: [PATCH] refactor(maglevc): build the CLI on the golang-cli library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace maglevc's hand-rolled command-tree CLI with git.ipng.ch/ipng/golang-cli v1.3.0, mirroring the evpnc refactor. The tree (commands.go) and the gRPC-status error unwrap (color.go) stay app-specific; the generic parts — parse tree, completion, '?'-help, the readline shell, one-shot dispatch, color helpers, and the watch keypress handler — now come from the library. - main.go: a single cli.App[grpcapi.MaglevClient] with a Connect callback; drops the flag/color-default/dispatch boilerplate. - commands.go: `type node = cli.Node[grpcapi.MaglevClient]`; label() -> cli.Label(); dyn* gain the captured-args parameter the library's Dynamic signature carries; runQuit returns cli.ErrQuit. - watch.go: keypress.WaitForKey replaces the inline cbreak helper. - color.go: only formatError remains, reading cli.ColorEnabled(). - delete tree.go, complete.go, shell.go. - tests use the library API; add TestTreeValid (cli.Validate). Behavior is unchanged except labels/errors now use the library's bright ANSI palette (was dark); escape lengths are identical so tabwriter alignment is unaffected. maglevc additionally gains the OpenBSD readline fix and BSD-correct watch keypress it previously lacked. Builds on linux and openbsd. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/client/color.go | 42 ++---- cmd/client/commands.go | 276 ++++++++++++++++++++-------------------- cmd/client/complete.go | 183 -------------------------- cmd/client/main.go | 99 +++++--------- cmd/client/shell.go | 123 ------------------ cmd/client/tree.go | 168 ------------------------ cmd/client/tree_test.go | 52 +++++--- cmd/client/watch.go | 53 +------- go.mod | 11 +- go.sum | 8 +- 10 files changed, 227 insertions(+), 788 deletions(-) delete mode 100644 cmd/client/complete.go delete mode 100644 cmd/client/shell.go delete mode 100644 cmd/client/tree.go diff --git a/cmd/client/color.go b/cmd/client/color.go index b97db20..71384a1 100644 --- a/cmd/client/color.go +++ b/cmd/client/color.go @@ -2,42 +2,18 @@ package main -import "strings" +import ( + "strings" -const ( - ansiBlue = "\x1b[34m" - ansiRed = "\x1b[31m" - ansiReset = "\x1b[0m" + cli "git.ipng.ch/ipng/golang-cli" ) -// 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. +// boilerplate). The result is wrapped in red when color is enabled. The label() +// helper and the ANSI palette now live in the golang-cli library (cli.Label, +// cli.Red, ...); only this gRPC-specific unwrap stays here, wired in as +// App.FormatError. func formatError(err error) string { msg := err.Error() // google.golang.org/grpc/status errors format as: @@ -45,8 +21,8 @@ func formatError(err error) string { if i := strings.Index(msg, " desc = "); i >= 0 { msg = msg[i+len(" desc = "):] } - if colorEnabled { - return ansiRed + msg + ansiReset + if cli.ColorEnabled() { + return cli.Red + msg + cli.Reset } return msg } diff --git a/cmd/client/commands.go b/cmd/client/commands.go index 4799754..2e55ced 100644 --- a/cmd/client/commands.go +++ b/cmd/client/commands.go @@ -11,82 +11,88 @@ import ( "text/tabwriter" "time" + cli "git.ipng.ch/ipng/golang-cli" 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: ""} +// node is the maglevc command tree node: a cli.Node specialized to the maglevd +// gRPC client. The alias keeps the tree literals below concise (&node{...} +// instead of &cli.Node[grpcapi.MaglevClient]{...}). +type node = cli.Node[grpcapi.MaglevClient] - 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} +// 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} + showVersion := &node{Word: "version", Help: "Show build version", Run: runShowVersion} // show frontends [] — without name: list all, with name: show details - showFrontendName := &Node{ + showFrontendName := &node{ Word: "", Help: "Show details for a single frontend", Dynamic: dynFrontends, Run: runShowFrontend, } - showFrontends := &Node{ + showFrontends := &node{ Word: "frontends", Help: "List all frontends", Run: runShowFrontends, - Children: []*Node{showFrontendName}, + Children: []*node{showFrontendName}, } // show backends [] — without name: list all, with name: show details - showBackendName := &Node{ + showBackendName := &node{ Word: "", Help: "Show details for a single backend", Dynamic: dynBackends, Run: runShowBackend, } - showBackends := &Node{ + showBackends := &node{ Word: "backends", Help: "List all backends", Run: runShowBackends, - Children: []*Node{showBackendName}, + Children: []*node{showBackendName}, } // show healthchecks [] — without name: list all, with name: show details - showHealthCheckName := &Node{ + showHealthCheckName := &node{ Word: "", Help: "Show details for a single health check", Dynamic: dynHealthChecks, Run: runShowHealthCheck, } - showHealthChecks := &Node{ + showHealthChecks := &node{ Word: "healthchecks", Help: "List all health checks", Run: runShowHealthChecks, - Children: []*Node{showHealthCheckName}, + 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{ + 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}, + Children: []*node{showVPPLBState, showVPPLBCounters}, } - showVPP := &Node{ + showVPP := &node{ Word: "vpp", Help: "VPP dataplane information", - Children: []*Node{showVPPInfo, showVPPLB}, + Children: []*node{showVPPInfo, showVPPLB}, } - show.Children = []*Node{ + show.Children = []*node{ showVersion, showFrontends, showBackends, @@ -95,20 +101,20 @@ func buildTree() *Node { } // set backend 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{ + 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: "", Help: "backend name", Dynamic: dynBackends, - Children: []*Node{setPause, setResume, setDisabled, setEnabled}, + Children: []*node{setPause, setResume, setDisabled, setEnabled}, } - setBackend := &Node{ + setBackend := &node{ Word: "backend", Help: "modify a backend", - Children: []*Node{setBackendName}, + Children: []*node{setBackendName}, } // set frontend pool backend weight <0-100> [flush] // @@ -116,120 +122,120 @@ func buildTree() *Node { // 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{ + 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{ + setWeightValue := &node{ Word: "", 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}, + Children: []*node{setWeightFlush}, } - setFrontendPoolBackendWeight := &Node{Word: "weight", Help: "set backend weight in pool", Children: []*Node{setWeightValue}} - setFrontendPoolBackendName := &Node{ + setFrontendPoolBackendWeight := &node{Word: "weight", Help: "set backend weight in pool", Children: []*node{setWeightValue}} + setFrontendPoolBackendName := &node{ Word: "", Help: "backend name", Dynamic: dynBackends, - Children: []*Node{setFrontendPoolBackendWeight}, + Children: []*node{setFrontendPoolBackendWeight}, } - setFrontendPoolBackend := &Node{Word: "backend", Help: "select a backend", Children: []*Node{setFrontendPoolBackendName}} - setFrontendPoolName := &Node{ + setFrontendPoolBackend := &node{Word: "backend", Help: "select a backend", Children: []*node{setFrontendPoolBackendName}} + setFrontendPoolName := &node{ Word: "", Help: "pool name", Dynamic: dynNone, // pool names aren't listed via gRPC; accepts any input - Children: []*Node{setFrontendPoolBackend}, + Children: []*node{setFrontendPoolBackend}, } - setFrontendPool := &Node{Word: "pool", Help: "select a pool", Children: []*Node{setFrontendPoolName}} - setFrontendName := &Node{ + setFrontendPool := &node{Word: "pool", Help: "select a pool", Children: []*node{setFrontendPoolName}} + setFrontendName := &node{ Word: "", Help: "frontend name", Dynamic: dynFrontends, - Children: []*Node{setFrontendPool}, + Children: []*node{setFrontendPool}, } - setFrontend := &Node{ + setFrontend := &node{ Word: "frontend", Help: "modify a frontend", - Children: []*Node{setFrontendName}, + Children: []*node{setFrontendName}, } - set.Children = []*Node{setBackend, setFrontend} + set.Children = []*node{setBackend, setFrontend} // watch events [num ] [log [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{ + watchEventsOptSlot := &node{ Word: "", Help: "Stream events with options", Dynamic: dynWatchEventOpts, Run: runWatchEvents, } - watchEventsOptSlot.Children = []*Node{watchEventsOptSlot} + watchEventsOptSlot.Children = []*node{watchEventsOptSlot} - watchEvents := &Node{ + watchEvents := &node{ Word: "events", Help: "stream events (press any key or Ctrl-C to stop)", Run: runWatchEvents, - Children: []*Node{watchEventsOptSlot}, + Children: []*node{watchEventsOptSlot}, } - watch := &Node{ + watch := &node{ Word: "watch", Help: "watch live event streams", - Children: []*Node{watchEvents}, + 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{ + 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}, + Children: []*node{configCheck, configReload}, } // sync vpp lb state [] // // Without a name: run SyncLBStateAll (may remove stale VIPs). // With a name: run SyncLBStateVIP(name) for just that frontend (no removals). - syncVPPLBStateName := &Node{ + syncVPPLBStateName := &node{ Word: "", Help: "Sync a single frontend's VIP to VPP", Dynamic: dynFrontends, Run: runSyncVPPLBState, } - syncVPPLBState := &Node{ + syncVPPLBState := &node{ Word: "state", Help: "Sync the VPP load-balancer dataplane from the running config", Run: runSyncVPPLBState, - Children: []*Node{syncVPPLBStateName}, + Children: []*node{syncVPPLBStateName}, } - syncVPPLB := &Node{ + syncVPPLB := &node{ Word: "lb", Help: "VPP load-balancer sync commands", - Children: []*Node{syncVPPLBState}, + Children: []*node{syncVPPLBState}, } - syncVPP := &Node{ + syncVPP := &node{ Word: "vpp", Help: "VPP dataplane sync commands", - Children: []*Node{syncVPPLB}, + Children: []*node{syncVPPLB}, } - syncNode := &Node{ + syncNode := &node{ Word: "sync", Help: "Reconcile dataplane state from the running config", - Children: []*Node{syncVPP}, + Children: []*node{syncVPP}, } - root.Children = []*Node{show, set, watch, configNode, syncNode, quit, exit} + root.Children = []*node{show, set, watch, configNode, syncNode, quit, exit} return root } // ---- dynamic enumerators --------------------------------------------------- -func dynFrontends(ctx context.Context, client grpcapi.MaglevClient) []string { +func dynFrontends(ctx context.Context, client grpcapi.MaglevClient, _ []string) []string { resp, err := client.ListFrontends(ctx, &grpcapi.ListFrontendsRequest{}) if err != nil { return nil @@ -237,7 +243,7 @@ func dynFrontends(ctx context.Context, client grpcapi.MaglevClient) []string { return resp.FrontendNames } -func dynBackends(ctx context.Context, client grpcapi.MaglevClient) []string { +func dynBackends(ctx context.Context, client grpcapi.MaglevClient, _ []string) []string { resp, err := client.ListBackends(ctx, &grpcapi.ListBackendsRequest{}) if err != nil { return nil @@ -245,7 +251,7 @@ func dynBackends(ctx context.Context, client grpcapi.MaglevClient) []string { return resp.BackendNames } -func dynHealthChecks(ctx context.Context, client grpcapi.MaglevClient) []string { +func dynHealthChecks(ctx context.Context, client grpcapi.MaglevClient, _ []string) []string { resp, err := client.ListHealthChecks(ctx, &grpcapi.ListHealthChecksRequest{}) if err != nil { return nil @@ -255,7 +261,7 @@ func dynHealthChecks(ctx context.Context, client grpcapi.MaglevClient) []string // 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 } +func dynNone(_ context.Context, _ grpcapi.MaglevClient, _ []string) []string { return nil } // ---- run functions --------------------------------------------------------- @@ -267,18 +273,18 @@ func runShowVPPInfo(ctx context.Context, client grpcapi.MaglevClient, _ []string 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) + _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("version"), info.Version) + _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("build-date"), info.BuildDate) + _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("build-dir"), info.BuildDirectory) + _, _ = fmt.Fprintf(w, "%s\t%d\n", cli.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"), + _, _ = fmt.Fprintf(w, "%s\t%s (%s)\n", cli.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"), + _, _ = fmt.Fprintf(w, "%s\t%s (%s)\n", cli.Label("connected"), connTime.Format("2006-01-02 15:04:05"), formatDuration(time.Since(connTime))) return w.Flush() @@ -294,21 +300,21 @@ func runShowVPPLBState(ctx context.Context, client grpcapi.MaglevClient, _ []str // ---- global config ---- w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintf(w, "%s\n", label("global")) + _, _ = fmt.Fprintf(w, "%s\n", cli.Label("global")) if state.Conf.Ip4SrcAddress != "" { - _, _ = fmt.Fprintf(w, " %s\t%s\n", label("ip4-src"), state.Conf.Ip4SrcAddress) + _, _ = fmt.Fprintf(w, " %s\t%s\n", cli.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%s\n", cli.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) + _, _ = fmt.Fprintf(w, " %s\t%d\n", cli.Label("sticky-buckets-per-core"), state.Conf.StickyBucketsPerCore) + _, _ = fmt.Fprintf(w, " %s\t%ds\n", cli.Label("flow-timeout"), state.Conf.FlowTimeout) if err := w.Flush(); err != nil { return err } if len(state.Vips) == 0 { - fmt.Println(label("vips") + " (none)") + fmt.Println(cli.Label("vips") + " (none)") return nil } @@ -316,21 +322,21 @@ func runShowVPPLBState(ctx context.Context, client grpcapi.MaglevClient, _ []str 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)) + _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("vip"), stripHostMask(v.Prefix)) + _, _ = fmt.Fprintf(w, " %s\t%s\n", cli.Label("protocol"), protoString(v.Protocol)) + _, _ = fmt.Fprintf(w, " %s\t%d\n", cli.Label("port"), v.Port) + _, _ = fmt.Fprintf(w, " %s\t%s\n", cli.Label("encap"), v.Encap) + _, _ = fmt.Fprintf(w, " %s\t%t\n", cli.Label("src-ip-sticky"), v.SrcIpSticky) + _, _ = fmt.Fprintf(w, " %s\t%d\n", cli.Label("flow-table-length"), v.FlowTableLength) + _, _ = fmt.Fprintf(w, " %s\t%d\n", cli.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) + cli.Label("address"), a.Address, + cli.Label("weight"), a.Weight, + cli.Label("flow-table-buckets"), a.NumBuckets) } } return nil @@ -356,15 +362,15 @@ func runShowVPPLBCounters(ctx context.Context, client grpcapi.MaglevClient, _ [] // ---- frontend-counters ---- // - // Column headers are plain strings, not label()-wrapped. tabwriter + // Column headers are plain strings, not cli.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 + // over-padded. The cli.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")) + fmt.Println(cli.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 { @@ -429,7 +435,7 @@ func runShowVersion(_ context.Context, _ grpcapi.MaglevClient, _ []string) error } func runQuit(_ context.Context, _ grpcapi.MaglevClient, _ []string) error { - return errQuit + return cli.ErrQuit } func runShowFrontends(ctx context.Context, client grpcapi.MaglevClient, _ []string) error { @@ -457,17 +463,17 @@ func runShowFrontend(ctx context.Context, client grpcapi.MaglevClient, args []st } 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) + _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("name"), info.Name) + _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("address"), info.Address) + _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("protocol"), info.Protocol) + _, _ = fmt.Fprintf(w, "%s\t%d\n", cli.Label("port"), info.Port) + _, _ = fmt.Fprintf(w, "%s\t%t\n", cli.Label("src-ip-sticky"), info.SrcIpSticky) + _, _ = fmt.Fprintf(w, "%s\t%t\n", cli.Label("flush-on-down"), info.FlushOnDown) if info.Description != "" { - _, _ = fmt.Fprintf(w, "%s\t%s\n", label("description"), info.Description) + _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("description"), info.Description) } if len(info.Pools) > 0 { - _, _ = fmt.Fprintf(w, "%s\n", label("pools")) + _, _ = fmt.Fprintf(w, "%s\n", cli.Label("pools")) } if err := w.Flush(); err != nil { return err @@ -484,7 +490,7 @@ func runShowFrontend(ctx context.Context, client grpcapi.MaglevClient, args []st 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) + fmt.Printf("%s%s%s%s%s\n", poolIndent, cli.Label("name"), namePad, poolSep, pool.Name) for i, pb := range pool.Backends { beInfo, beErr := client.GetBackend(ctx, &grpcapi.GetBackendRequest{Name: pb.Name}) suffix := "" @@ -496,11 +502,11 @@ func runShowFrontend(ctx context.Context, client grpcapi.MaglevClient, args []st // 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) + cli.Label("weight"), pb.Weight, + cli.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) + fmt.Printf("%s%s%s%s%s%s%s\n", poolIndent, cli.Label("backends"), bePad, poolSep, pb.Name, metaStr, suffix) } else { fmt.Printf("%s%s%s%s\n", contIndent, pb.Name, metaStr, suffix) } @@ -533,26 +539,26 @@ func runShowBackend(ctx context.Context, client grpcapi.MaglevClient, args []str 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", cli.Label("name"), info.Name) + _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.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) + _, _ = fmt.Fprintf(w, "%s\t%s%s\n", cli.Label("state"), info.State, stateDur) + _, _ = fmt.Fprintf(w, "%s\t%v\n", cli.Label("enabled"), info.Enabled) + _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.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") + lbl = cli.Label("transitions") } else { // Pad to same visible width as "transitions" and wrap through - // label() so tabwriter sees the same byte count (ANSI overhead + // cli.Label() so tabwriter sees the same byte count (ANSI overhead // is identical on every row, keeping columns aligned). - lbl = label(" ") + lbl = cli.Label(" ") } _, _ = fmt.Fprintf(w, "%s\t%s → %s\t%s\t%s\n", lbl, @@ -588,41 +594,41 @@ func runShowHealthCheck(ctx context.Context, client grpcapi.MaglevClient, args [ 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) + _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("name"), info.Name) + _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("type"), info.Type) if info.Port > 0 { - _, _ = fmt.Fprintf(w, "%s\t%d\n", label("port"), info.Port) + _, _ = fmt.Fprintf(w, "%s\t%d\n", cli.Label("port"), info.Port) } - _, _ = fmt.Fprintf(w, "%s\t%s\n", label("interval"), time.Duration(info.IntervalNs)) + _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("interval"), time.Duration(info.IntervalNs)) if info.FastIntervalNs > 0 { - _, _ = fmt.Fprintf(w, "%s\t%s\n", label("fast-interval"), time.Duration(info.FastIntervalNs)) + _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.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", cli.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) + _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("timeout"), time.Duration(info.TimeoutNs)) + _, _ = fmt.Fprintf(w, "%s\t%d\n", cli.Label("rise"), info.Rise) + _, _ = fmt.Fprintf(w, "%s\t%d\n", cli.Label("fall"), info.Fall) if info.ProbeIpv4Src != "" { - _, _ = fmt.Fprintf(w, "%s\t%s\n", label("probe-ipv4-src"), info.ProbeIpv4Src) + _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("probe-ipv4-src"), info.ProbeIpv4Src) } if info.ProbeIpv6Src != "" { - _, _ = fmt.Fprintf(w, "%s\t%s\n", label("probe-ipv6-src"), info.ProbeIpv6Src) + _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("probe-ipv6-src"), info.ProbeIpv6Src) } if h := info.Http; h != nil { - _, _ = fmt.Fprintf(w, "%s\t%s\n", label("http.path"), h.Path) + _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.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%s\n", cli.Label("http.host"), h.Host) } - _, _ = fmt.Fprintf(w, "%s\t%d-%d\n", label("http.response-code"), h.ResponseCodeMin, h.ResponseCodeMax) + _, _ = fmt.Fprintf(w, "%s\t%d-%d\n", cli.Label("http.response-code"), h.ResponseCodeMin, h.ResponseCodeMax) if h.ResponseRegexp != "" { - _, _ = fmt.Fprintf(w, "%s\t%s\n", label("http.response-regexp"), h.ResponseRegexp) + _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("http.response-regexp"), h.ResponseRegexp) } } if t := info.Tcp; t != nil { - _, _ = fmt.Fprintf(w, "%s\t%v\n", label("tcp.ssl"), t.Ssl) + _, _ = fmt.Fprintf(w, "%s\t%v\n", cli.Label("tcp.ssl"), t.Ssl) if t.ServerName != "" { - _, _ = fmt.Fprintf(w, "%s\t%s\n", label("tcp.server-name"), t.ServerName) + _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("tcp.server-name"), t.ServerName) } } return w.Flush() diff --git a/cmd/client/complete.go b/cmd/client/complete.go deleted file mode 100644 index ad3a0ab..0000000 --- a/cmd/client/complete.go +++ /dev/null @@ -1,183 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -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(), " \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) -} diff --git a/cmd/client/main.go b/cmd/client/main.go index 79bb84a..1894e8b 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -1,94 +1,53 @@ // SPDX-License-Identifier: Apache-2.0 +// Command maglevc is the vpp-maglev CLI. It talks only to maglevd. With no +// arguments it starts an interactive shell (readline, tab-completion, '?' help, +// prefix abbreviation); with arguments it runs one command and exits. The +// command set is a single declarative tree (buildTree) -- dispatch, help, and +// completion are all derived from it, via the git.ipng.ch/ipng/golang-cli +// library. package main import ( "context" - "flag" "fmt" - "os" - "strings" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" + cli "git.ipng.ch/ipng/golang-cli" buildinfo "git.ipng.ch/ipng/vpp-maglev/cmd" "git.ipng.ch/ipng/vpp-maglev/internal/grpcapi" "git.ipng.ch/ipng/vpp-maglev/internal/netutil" ) -// defaultGRPCPort is the maglevd gRPC port (mirrors the server's -// -grpc-addr default in cmd/server/main.go). Used when -server is given -// without an explicit ":" so operators can type "--server chbtl2" -// instead of "--server chbtl2:9090". +// defaultGRPCPort is the maglevd gRPC port (mirrors the server's -grpc-addr +// default), used when -server is given without an explicit ":". const defaultGRPCPort = "9090" func main() { - if err := run(); err != nil { - fmt.Fprintf(os.Stderr, "%s\n", formatError(err)) - os.Exit(1) - } + (&cli.App[grpcapi.MaglevClient]{ + Name: "maglevc", + Version: buildinfo.Version(), + Commit: buildinfo.Commit(), + Date: buildinfo.Date(), + Prompt: "maglev> ", + Root: buildTree(), + DefaultServer: "localhost:9090", + ServerEnv: "MAGLEV_SERVER", + Connect: connect, + FormatError: formatError, + }).Main() } -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 - } - }) - - addr := netutil.EnsurePort(*serverAddr, defaultGRPCPort) - conn, err := grpc.NewClient(addr, - grpc.WithTransportCredentials(insecure.NewCredentials())) +// connect dials maglevd and returns the gRPC client. App resolves -server (env +// MAGLEV_SERVER, default localhost:9090) and hands us the raw address; we ensure +// a port and dial insecure, mirroring maglevd's default transport. +func connect(_ context.Context, server string) (grpcapi.MaglevClient, func(), error) { + addr := netutil.EnsurePort(server, defaultGRPCPort) + conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - return fmt.Errorf("connect %s: %w", addr, err) + return nil, nil, fmt.Errorf("connect %s: %w", addr, 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) + return grpcapi.NewMaglevClient(conn), func() { _ = conn.Close() }, nil } diff --git a/cmd/client/shell.go b/cmd/client/shell.go deleted file mode 100644 index c955edd..0000000 --- a/cmd/client/shell.go +++ /dev/null @@ -1,123 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -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(" ") - 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) - } - } -} diff --git a/cmd/client/tree.go b/cmd/client/tree.go deleted file mode 100644 index 10070d9..0000000 --- a/cmd/client/tree.go +++ /dev/null @@ -1,168 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -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 -} diff --git a/cmd/client/tree_test.go b/cmd/client/tree_test.go index 2d7c935..62b423c 100644 --- a/cmd/client/tree_test.go +++ b/cmd/client/tree_test.go @@ -5,11 +5,13 @@ package main import ( "strings" "testing" + + cli "git.ipng.ch/ipng/golang-cli" ) func TestExpandPathsRoot(t *testing.T) { root := buildTree() - lines := expandPaths(root, "", make(map[*Node]bool)) + lines := cli.ExpandPaths(root, "") // Should include well-known leaf paths. want := []string{ @@ -41,7 +43,7 @@ func TestExpandPathsRoot(t *testing.T) { paths := make(map[string]bool, len(lines)) for _, l := range lines { - paths[l.path] = true + paths[l.Path] = true } for _, w := range want { @@ -53,15 +55,15 @@ func TestExpandPathsRoot(t *testing.T) { func TestExpandPathsShow(t *testing.T) { root := buildTree() - showNode, _, _ := Walk(root, []string{"show"}) - lines := expandPaths(showNode, "show", make(map[*Node]bool)) + showNode, _, _ := cli.Walk(root, []string{"show"}) + lines := cli.ExpandPaths(showNode, "show") for _, l := range lines { - if !strings.HasPrefix(l.path, "show ") { - t.Errorf("unexpected path %q: should start with 'show '", l.path) + 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) + if l.Help == "" { + t.Errorf("path %q has empty help", l.Path) } } // version, frontends, frontends , backends, backends , @@ -75,8 +77,8 @@ func TestExpandPathsShow(t *testing.T) { 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)) + watchEvents, _, _ := cli.Walk(root, []string{"watch", "events"}) + lines := cli.ExpandPaths(watchEvents, "watch events") // Should produce exactly 2 lines: "watch events" and "watch events ". if len(lines) != 2 { @@ -87,8 +89,8 @@ func TestExpandPathsNoCycles(t *testing.T) { 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)) + node, _, _ := cli.Walk(root, []string{"set", "backend", "mybackend"}) + lines := cli.ExpandPaths(node, "set backend mybackend") want := []string{ "set backend mybackend pause", @@ -100,8 +102,8 @@ func TestExpandPathsSetBackendName(t *testing.T) { 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) + if lines[i].Path != w { + t.Errorf("line %d: got %q, want %q", i, lines[i].Path, w) } } } @@ -110,7 +112,7 @@ func TestPrefixMatchCollapsedNouns(t *testing.T) { root := buildTree() // "sh ba" → show backends (list all) via prefix matching. - node, args, rem := Walk(root, []string{"sh", "ba"}) + node, args, rem := cli.Walk(root, []string{"sh", "ba"}) if node.Run == nil { t.Fatal("'sh ba' did not reach a Run node") } @@ -122,7 +124,7 @@ func TestPrefixMatchCollapsedNouns(t *testing.T) { } // "sh ba nginx0" → show backends (get specific) via slot. - node, args, rem = Walk(root, []string{"sh", "ba", "nginx0"}) + node, args, rem = cli.Walk(root, []string{"sh", "ba", "nginx0"}) if node.Run == nil { t.Fatal("'sh ba nginx0' did not reach a Run node") } @@ -134,13 +136,13 @@ func TestPrefixMatchCollapsedNouns(t *testing.T) { } // "sh fr" → show frontends (list all). - node, _, _ = Walk(root, []string{"sh", "fr"}) + node, _, _ = cli.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"}) + node, args, _ = cli.Walk(root, []string{"sh", "he", "icmp"}) if node.Run == nil { t.Fatal("'sh he icmp' did not reach a Run node") } @@ -155,7 +157,7 @@ func TestWalkUnknownTokens(t *testing.T) { // 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"}) + node, _, rem := cli.Walk(root, []string{"foo"}) if node != root { t.Errorf("'foo' should leave walk at root, got %q", node.Word) } @@ -166,7 +168,7 @@ func TestWalkUnknownTokens(t *testing.T) { // 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"}) + node, _, rem = cli.Walk(root, []string{"show", "bogus", "tail"}) if node.Word != "show" { t.Errorf("'show bogus tail' should stop at show, got %q", node.Word) } @@ -179,7 +181,7 @@ 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"}) + node, args, _ := cli.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") } @@ -190,3 +192,11 @@ func TestExpandPathsWeightSlotWalk(t *testing.T) { t.Errorf("args[3] (weight): got %q, want 42", args[3]) } } + +// TestTreeValid guards the command tree against authoring faults (more than one +// slot child per node, empty words, duplicate siblings, dead ends). +func TestTreeValid(t *testing.T) { + if err := cli.Validate(buildTree()); err != nil { + t.Fatalf("buildTree() has authoring faults:\n%v", err) + } +} diff --git a/cmd/client/watch.go b/cmd/client/watch.go index 0e55c3c..b0055ad 100644 --- a/cmd/client/watch.go +++ b/cmd/client/watch.go @@ -10,15 +10,15 @@ import ( "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/golang-cli/keypress" "git.ipng.ch/ipng/vpp-maglev/internal/grpcapi" ) -func dynWatchEventOpts(_ context.Context, _ grpcapi.MaglevClient) []string { +func dynWatchEventOpts(_ context.Context, _ grpcapi.MaglevClient, _ []string) []string { return []string{"num", "log", "backend", "frontend"} } @@ -84,7 +84,7 @@ func runWatchEvents(ctx context.Context, client grpcapi.MaglevClient, args []str watchCtx, cancel := context.WithCancel(ctx) defer cancel() - go watchStopOnKeypress(watchCtx, cancel) + go keypress.WaitForKey(watchCtx, cancel) sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) @@ -127,48 +127,5 @@ func runWatchEvents(ctx context.Context, client grpcapi.MaglevClient, args []str } } -// 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 -} +// Stopping the watch on a keypress now uses keypress.WaitForKey from the +// golang-cli library (per-platform cbreak handling, with a non-tty fallback). diff --git a/go.mod b/go.mod index 2094039..70e0841 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,14 @@ module git.ipng.ch/ipng/vpp-maglev go 1.25.0 require ( - github.com/chzyer/readline v1.5.1 + git.ipng.ch/ipng/golang-cli v1.3.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 github.com/prometheus/client_golang v1.23.2 github.com/vishvananda/netns v0.0.5 go.fd.io/govpp v0.12.0 golang.org/x/net v0.52.0 - golang.org/x/sys v0.43.0 google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 @@ -18,16 +20,14 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/chzyer/readline v1.5.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/ftrvxmtrx/fd v0.0.0-20150925145434-c6d800382fff // indirect - github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect @@ -46,6 +46,7 @@ require ( github.com/sirupsen/logrus v1.9.3 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.35.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect ) diff --git a/go.sum b/go.sum index 4d50eb8..d9fd393 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +git.ipng.ch/ipng/golang-cli v1.3.0 h1:E26W55czJSl+kmSXpwFWxYHsnaT84unsNBtfdM3iT4M= +git.ipng.ch/ipng/golang-cli v1.3.0/go.mod h1:8yeV4X7MF5hKQnxnYYJKyqby4P58EtH82zd36BrNbqY= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -117,14 +119,16 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc= +golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= 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/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=