5 Commits

Author SHA1 Message Date
Pim van Pelt 12f17c0075 docs(maglevc): document -json; verified live against maglevd
Add the -json flag to maglevc(1): the contract verified by a live smoke
test against a maglevd (chbtl2) — show/query commands print the object
as JSON, actions (set/sync/config reload) print {} on success, and
failures print {"error": "..."} on stderr with a non-zero exit. JSON
output is never colorized. Also corrects the -color description (the
palette is now bright blue, shared from golang-cli) and refreshes the
man date.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:43:17 +02:00
Pim van Pelt d9a8ca6fb8 feat(maglevc): JSON output for all commands (-json)
Enable App.JSON and add -json on golang-cli v1.4.0, mirroring evpnc so
the two CLIs' JSON contract is identical: show/query -> data, set/action
-> {}, failure -> {"error": "..."}.

- show/query commands branch on cli.IsJSON() -> emit the protobuf via
  cli.EmitJSON; text keeps the tabwriter painters (which robot tests
  parse via show, not via setter output).
- action commands (pause/resume/enable/disable, set weight, sync,
  config reload) are now silent on success in text too — "we did what
  you asked" needs no confirmation — and print "{}" in JSON via wrapJSON.
- config check stays informative (it is a query): text "config ok" /
  error, JSON the CheckConfig report.
- errors: formatError returns {"error": "..."} in JSON mode.
- watch streams its own JSON events (no trailing {}).

Robot tests assert backend state via `show`, not setter stdout, so the
dropped confirmations don't affect them. Builds on linux and openbsd.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:31:21 +02:00
Pim van Pelt 76fbe2eee0 refactor(maglevc): build the CLI on the golang-cli library
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) <noreply@anthropic.com>
2026-06-05 22:38:34 +02:00
Pim van Pelt d2ee6d009e v1.1.3: surface daemon version + commit on maglev_info
Extend the existing maglev_info gauge with two more labels:
- version (build version, from -ldflags)
- commit (git short hash)

Mirrors nginx_ipng_stats-plugin's nginx_ipng_info pattern: a
label-only gauge value=1 carrying build metadata for Prometheus
to query and Grafana dashboards to render. Useful for spotting
version drift across the fleet with a single query, and for
populating the "Version" column of the Maglev Nodes table on
the global overview.

Existing label source_tag is preserved.

Plumbing: NewCollector / Register signatures gain version and
commit string parameters; main.go reads them from cmd.Version()
and cmd.Commit() (already imported as 'buildinfo').
2026-05-01 23:20:27 +02:00
Pim van Pelt dc7599f3ee Move source-tag to toplevel maglev.source-tag config, defaulting to short hostname, v1.1.2 2026-05-01 15:39:23 +02:00
17 changed files with 454 additions and 887 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ FRONTEND_WEB_SRC := $(shell find cmd/frontend/web/src -type f 2>/dev/null) \
FRONTEND_WEB_DIST := cmd/frontend/web/dist/index.html
NATIVE_ARCH := $(shell go env GOARCH)
VERSION := 1.1.1
VERSION := 1.1.3
COMMIT_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -X '$(MODULE)/cmd.version=$(VERSION)' \
+14 -33
View File
@@ -2,42 +2,19 @@
package main
import "strings"
import (
"encoding/json"
"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). In JSON mode it returns the message as a {"error": "..."}
// document (so failures are machine-readable, matching the data/`{}` a success
// prints); otherwise it wraps the message in red when color is enabled.
// App.FormatError prints the result.
func formatError(err error) string {
msg := err.Error()
// google.golang.org/grpc/status errors format as:
@@ -45,8 +22,12 @@ 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.IsJSON() {
b, _ := json.Marshal(map[string]string{"error": msg})
return string(b)
}
if cli.ColorEnabled() {
return cli.Red + msg + cli.Reset
}
return msg
}
+195 -193
View File
@@ -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 [<name>] — without name: list all, with name: show details
showFrontendName := &Node{
showFrontendName := &node{
Word: "<name>",
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 [<name>] — without name: list all, with name: show details
showBackendName := &Node{
showBackendName := &node{
Word: "<name>",
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 [<name>] — without name: list all, with name: show details
showHealthCheckName := &Node{
showHealthCheckName := &node{
Word: "<name>",
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 <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{
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},
Children: []*node{setPause, setResume, setDisabled, setEnabled},
}
setBackend := &Node{
setBackend := &node{
Word: "backend",
Help: "modify a backend",
Children: []*Node{setBackendName},
Children: []*node{setBackendName},
}
// set frontend <name> pool <pool> backend <name> weight <0-100> [flush]
//
@@ -116,120 +122,123 @@ 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: "<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},
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: "<backend>",
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: "<pool>",
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: "<name>",
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 <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{
watchEventsOptSlot := &node{
Word: "<opt>",
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 [<name>]
//
// 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: "<name>",
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}
// In -json mode, make every silent successful command print "{}" (see
// wrapJSON); show/return-data commands emit their own JSON first.
wrapJSON(root)
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 +246,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 +254,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 +264,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 ---------------------------------------------------------
@@ -266,19 +275,22 @@ func runShowVPPInfo(ctx context.Context, client grpcapi.MaglevClient, _ []string
if err != nil {
return err
}
if cli.IsJSON() {
return emitProto(info)
}
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()
@@ -291,24 +303,27 @@ func runShowVPPLBState(ctx context.Context, client grpcapi.MaglevClient, _ []str
if err != nil {
return err
}
if cli.IsJSON() {
return emitProto(state)
}
// ---- 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 +331,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
@@ -348,6 +363,9 @@ func runShowVPPLBCounters(ctx context.Context, client grpcapi.MaglevClient, _ []
if err != nil {
return err
}
if cli.IsJSON() {
return emitProto(resp)
}
if len(resp.Vips) == 0 {
fmt.Println("(no counters — VPP disconnected or scrape pending)")
@@ -356,15 +374,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 {
@@ -411,25 +429,25 @@ func runSyncVPPLBState(ctx context.Context, client grpcapi.MaglevClient, args []
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
_, err := client.SyncVPPLBState(ctx, req)
return err
}
func runShowVersion(_ context.Context, _ grpcapi.MaglevClient, _ []string) error {
if cli.IsJSON() {
return emitJSON(map[string]string{
"version": buildinfo.Version(),
"commit": buildinfo.Commit(),
"built": buildinfo.Date(),
})
}
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
return cli.ErrQuit
}
func runShowFrontends(ctx context.Context, client grpcapi.MaglevClient, _ []string) error {
@@ -439,6 +457,9 @@ func runShowFrontends(ctx context.Context, client grpcapi.MaglevClient, _ []stri
if err != nil {
return err
}
if cli.IsJSON() {
return emitProto(resp)
}
for _, name := range resp.FrontendNames {
fmt.Println(name)
}
@@ -455,19 +476,22 @@ func runShowFrontend(ctx context.Context, client grpcapi.MaglevClient, args []st
if err != nil {
return err
}
if cli.IsJSON() {
return emitProto(info)
}
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 +508,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 +520,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)
}
@@ -516,6 +540,9 @@ func runShowBackends(ctx context.Context, client grpcapi.MaglevClient, _ []strin
if err != nil {
return err
}
if cli.IsJSON() {
return emitProto(resp)
}
for _, name := range resp.BackendNames {
fmt.Println(name)
}
@@ -532,27 +559,30 @@ func runShowBackend(ctx context.Context, client grpcapi.MaglevClient, args []str
if err != nil {
return err
}
if cli.IsJSON() {
return emitProto(info)
}
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,
@@ -571,6 +601,9 @@ func runShowHealthChecks(ctx context.Context, client grpcapi.MaglevClient, _ []s
if err != nil {
return err
}
if cli.IsJSON() {
return emitProto(resp)
}
for _, name := range resp.Names {
fmt.Println(name)
}
@@ -587,42 +620,45 @@ func runShowHealthCheck(ctx context.Context, client grpcapi.MaglevClient, args [
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)
if cli.IsJSON() {
return emitProto(info)
}
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("interval"), time.Duration(info.IntervalNs))
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
_, _ = 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", cli.Label("port"), info.Port)
}
_, _ = 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()
@@ -634,12 +670,8 @@ func runPauseBackend(ctx context.Context, client grpcapi.MaglevClient, args []st
}
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
_, err := client.PauseBackend(ctx, &grpcapi.BackendRequest{Name: args[0]})
return err
}
func runResumeBackend(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
@@ -648,12 +680,8 @@ func runResumeBackend(ctx context.Context, client grpcapi.MaglevClient, args []s
}
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
_, err := client.ResumeBackend(ctx, &grpcapi.BackendRequest{Name: args[0]})
return err
}
func runSetFrontendPoolBackendWeight(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
@@ -675,34 +703,14 @@ func setFrontendPoolBackendWeight(ctx context.Context, client grpcapi.MaglevClie
}
ctx, cancel := context.WithTimeout(ctx, callTimeout)
defer cancel()
info, err := client.SetFrontendPoolBackendWeight(ctx, &grpcapi.SetWeightRequest{
_, 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
return err
}
func runEnableBackend(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
@@ -711,12 +719,8 @@ func runEnableBackend(ctx context.Context, client grpcapi.MaglevClient, args []s
}
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
_, err := client.EnableBackend(ctx, &grpcapi.BackendRequest{Name: args[0]})
return err
}
func runDisableBackend(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
@@ -725,12 +729,8 @@ func runDisableBackend(ctx context.Context, client grpcapi.MaglevClient, args []
}
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
_, err := client.DisableBackend(ctx, &grpcapi.BackendRequest{Name: args[0]})
return err
}
func runConfigCheck(ctx context.Context, client grpcapi.MaglevClient, _ []string) error {
@@ -740,6 +740,9 @@ func runConfigCheck(ctx context.Context, client grpcapi.MaglevClient, _ []string
if err != nil {
return err
}
if cli.IsJSON() {
return emitProto(resp)
}
if resp.Ok {
fmt.Println("config ok")
return nil
@@ -758,7 +761,6 @@ func runConfigReload(ctx context.Context, client grpcapi.MaglevClient, _ []strin
return err
}
if resp.Ok {
fmt.Println("config reloaded")
return nil
}
if resp.ParseError != "" {
-183
View File
@@ -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(), " <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
View File
@@ -0,0 +1,86 @@
// SPDX-License-Identifier: Apache-2.0
package main
import (
"context"
"encoding/json"
"fmt"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
cli "git.ipng.ch/ipng/golang-cli"
"git.ipng.ch/ipng/vpp-maglev/internal/grpcapi"
)
// JSON output (-json). The protobuf response is the model: in JSON mode each
// command emits it via the shared cli.EmitJSON (protojson field names/enums); in
// text mode it keeps its curated tabwriter painter (which robot tests parse, and
// which carries column alignment JSON does not need). A few commands print only
// a confirmation in text mode — those emit the returned proto in JSON, or, when
// there is nothing to return, rely on wrapJSON to print "{}". Errors render as
// {"error": "..."} (see color.go).
// jsonEmitted records whether the current command produced JSON output of its
// own. Commands run serially, so a package var is safe. wrapJSON resets it
// before each command and appends "{}" for a successful command that produced
// nothing.
var jsonEmitted bool
// emitProto emits one proto message as JSON via the shared renderer.
func emitProto(m proto.Message) error {
raw, err := protoField(m)
if err != nil {
return err
}
jsonEmitted = true
return cli.EmitJSON(raw)
}
// emitJSON emits a composite value (maps/structs) as JSON.
func emitJSON(v any) error {
jsonEmitted = true
return cli.EmitJSON(v)
}
// protoField marshals a proto message to compact JSON (proto field names and
// enum spellings).
func protoField(m proto.Message) (json.RawMessage, error) {
b, err := protojson.Marshal(m)
if err != nil {
return nil, fmt.Errorf("marshal json: %w", err)
}
return json.RawMessage(b), nil
}
// wrapJSON decorates every runnable node so that, in -json mode, a command that
// succeeds without emitting output of its own (e.g. sync) still produces "{}",
// giving every command a uniform JSON contract. Commands that show or return
// data emit it (setting jsonEmitted); errors propagate to App.FormatError, which
// renders them as {"error": "..."}. In text mode the wrapper is a no-op.
func wrapJSON(root *node) {
seen := map[*node]bool{}
var walk func(*node)
walk = func(n *node) {
if n == nil || seen[n] {
return
}
seen[n] = true
if n.Run != nil {
orig := n.Run
n.Run = func(ctx context.Context, c grpcapi.MaglevClient, a []string) error {
jsonEmitted = false
err := orig(ctx, c, a)
if err == nil && cli.IsJSON() && !jsonEmitted {
return emitJSON(struct{}{})
}
return err
}
}
for _, ch := range n.Children {
walk(ch)
}
}
walk(root)
}
+30 -70
View File
@@ -1,94 +1,54 @@
// 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 ":<port>" 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 ":<port>".
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(),
JSON: true, // commands render via cli.IsJSON() (see json.go)
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
}
-123
View File
@@ -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(" <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
View File
@@ -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
}
+31 -21
View File
@@ -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 <name>, backends, backends <name>,
@@ -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 <opt>".
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 <name> (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)
}
}
+7 -49
View File
@@ -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"}
}
@@ -26,7 +26,8 @@ func dynWatchEventOpts(_ context.Context, _ grpcapi.MaglevClient) []string {
// 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
jsonEmitted = true // watch streams its own JSON event lines; never append "{}"
var maxEvents int // 0 = unlimited
var wantLog, wantBackend, wantFrontend bool
logLevel := ""
anyExplicit := false
@@ -84,7 +85,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 +128,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).
+1 -1
View File
@@ -150,7 +150,7 @@ func run() error {
if vppClient != nil {
vppSrc = vppClient
}
metrics.Register(reg, chkr, vppSrc)
metrics.Register(reg, chkr, vppSrc, cfg.SourceTag, buildinfo.Version(), buildinfo.Commit())
reg.MustRegister(grpcMetrics)
mux := http.NewServeMux()
+29 -2
View File
@@ -1,10 +1,11 @@
.TH MAGLEVC 1 "April 2026" "vpp\-maglev" "User Commands"
.TH MAGLEVC 1 "June 2026" "vpp\-maglev" "User Commands"
.SH NAME
maglevc \- Maglev health\-checker CLI client
.SH SYNOPSIS
.B maglevc
[\fB\-server\fR \fIaddr\fR]
[\fB\-color\fR[=\fIbool\fR]]
[\fB\-json\fR]
[\fIcommand\fR [\fIargs\fR...]]
.SH DESCRIPTION
.B maglevc
@@ -38,7 +39,7 @@ gRPC server.
.RI "(default: " localhost:9090 "; env: " MAGLEV_SERVER )
.TP
.BR \-color [=\fIbool\fR]
Colorize static field labels in output using ANSI dark blue. The
Colorize static field labels in output using ANSI blue. The
default is mode\-aware: enabled (true) in the interactive shell, and
disabled (false) in one\-shot mode so that output piped into scripts
or files stays free of escape codes. Pass
@@ -47,6 +48,18 @@ or
.B \-color=false
explicitly to override the default for either mode.
.TP
.B \-json
Emit JSON instead of human\-readable text, for scripting. Every
.B show
or query command prints the underlying object as JSON; an action that
changes state
.RB ( set ", " sync ", " "config reload" )
prints
.B {}
on success; and any failure prints
.B {\(dqerror\(dq: \(dq...\(dq}
on stderr with a non\-zero exit status. JSON output is never colorized.
.TP
.B \-version
Print version, commit hash, and build date, then exit.
.SH EXAMPLES
@@ -84,6 +97,20 @@ Query VPP version and connection status, forcing color on:
$ maglevc \-color=true show vpp info
.EE
.RE
.PP
Emit JSON for scripting. A query returns the object; an action returns
.BR {} ;
a failure returns
.B {"error": ...}
with a non\-zero exit status:
.PP
.RS
.EX
$ maglevc \-json show frontend nginx\-ip4\-http
$ maglevc \-json sync vpp lb state
{}
.EE
.RE
.SH "FULL DOCUMENTATION"
This manpage documents only the invocation of
.BR maglevc .
+6 -5
View File
@@ -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.4.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
)
+8 -2
View File
@@ -1,3 +1,7 @@
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=
git.ipng.ch/ipng/golang-cli v1.4.0 h1:KpvVqkieYoH3en7ay0QGmagiYcXJoUAiU49OLx3eZGw=
git.ipng.ch/ipng/golang-cli v1.4.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 +121,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=
+3 -4
View File
@@ -378,10 +378,9 @@ func (c *Checker) GetBackendInfo(name string) (metrics.BackendInfo, bool) {
return metrics.BackendInfo{}, false
}
return metrics.BackendInfo{
Health: w.backend,
Enabled: w.entry.Enabled,
HCName: w.entry.HealthCheck,
SourceTag: w.entry.SourceTag,
Health: w.backend,
Enabled: w.entry.Enabled,
HCName: w.entry.HealthCheck,
}, true
}
+15 -8
View File
@@ -17,6 +17,7 @@ import (
// Config is the top-level parsed and validated configuration.
type Config struct {
SourceTag string // this node's nginx source tag; defaults to the short hostname
HealthChecker HealthCheckerConfig
VPP VPPConfig
HealthChecks map[string]HealthCheck
@@ -123,7 +124,6 @@ type TCPParams struct {
type Backend struct {
Address net.IP
HealthCheck string // name reference into Config.HealthChecks; "" = no probing, assume healthy
SourceTag string // nginx source tag; defaults to the backend name if omitted from config
Enabled bool // default true; false = exclude from serving entirely
}
@@ -164,6 +164,7 @@ type rawConfig struct {
}
type rawMaglev struct {
SourceTag string `yaml:"source-tag"`
HealthChecker rawHealthCheckerCfg `yaml:"healthchecker"`
VPP rawVPPCfg `yaml:"vpp"`
HealthChecks map[string]rawHealthCheck `yaml:"healthchecks"`
@@ -219,8 +220,7 @@ type rawParams struct {
type rawBackend struct {
Address string `yaml:"address"`
HealthCheck string `yaml:"healthcheck"`
SourceTag string `yaml:"source-tag"` // defaults to backend name if omitted
Enabled *bool `yaml:"enabled"` // nil → default true
Enabled *bool `yaml:"enabled"` // nil → default true
}
type rawPoolBackend struct {
@@ -301,6 +301,18 @@ func parse(data []byte) (*Config, error) {
func convert(r *rawMaglev) (*Config, error) {
cfg := &Config{}
// ---- source-tag -----------------------------------------------------------
cfg.SourceTag = r.SourceTag
if cfg.SourceTag == "" {
if h, err := os.Hostname(); err == nil {
if dot := strings.IndexByte(h, '.'); dot > 0 {
cfg.SourceTag = h[:dot]
} else {
cfg.SourceTag = h
}
}
}
// ---- healthchecker --------------------------------------------------------
cfg.HealthChecker.Netns = r.HealthChecker.Netns
cfg.HealthChecker.TransitionHistory = r.HealthChecker.TransitionHistory
@@ -590,14 +602,9 @@ func convertBackend(name string, r *rawBackend, hcs map[string]HealthCheck) (Bac
return Backend{}, fmt.Errorf("invalid address %q", r.Address)
}
sourceTag := r.SourceTag
if sourceTag == "" {
sourceTag = name
}
b := Backend{
Address: ip,
HealthCheck: r.HealthCheck,
SourceTag: sourceTag,
Enabled: boolDefault(r.Enabled, true),
}
+28 -24
View File
@@ -21,10 +21,9 @@ import (
// BackendInfo holds the health and config state needed by the collector.
type BackendInfo struct {
Health *health.Backend
Enabled bool
HCName string // healthcheck name from config
SourceTag string // nginx source tag; equals backend name when unset in config
Health *health.Backend
Enabled bool
HCName string // healthcheck name from config
}
// StateSource provides read-only access to the running checker state.
@@ -132,10 +131,13 @@ var (
// on each scrape. This avoids stale label sets when backends are added or
// removed by a config reload.
type Collector struct {
src StateSource
vpp VPPSource // optional; nil when VPP integration is disabled
src StateSource
vpp VPPSource // optional; nil when VPP integration is disabled
sourceTag string
version string
commit string
backendInfo *prometheus.Desc
maglevInfo *prometheus.Desc
backendState *prometheus.Desc
backendHealth *prometheus.Desc
backendEnabled *prometheus.Desc
@@ -153,15 +155,18 @@ type Collector struct {
// NewCollector creates a Collector backed by the given StateSource. vpp may
// be nil when VPP integration is disabled; in that case vpp_* metrics are
// simply not emitted.
func NewCollector(src StateSource, vpp VPPSource) *Collector {
// simply not emitted. version and commit are surfaced via maglev_info labels.
func NewCollector(src StateSource, vpp VPPSource, sourceTag, version, commit string) *Collector {
return &Collector{
src: src,
vpp: vpp,
backendInfo: prometheus.NewDesc(
"maglev_backend_info",
"Static backend metadata. Always 1; metadata is conveyed via labels.",
[]string{"backend", "address", "healthcheck", "source_tag"}, nil,
src: src,
vpp: vpp,
sourceTag: sourceTag,
version: version,
commit: commit,
maglevInfo: prometheus.NewDesc(
"maglev_info",
"Static maglevd instance metadata. Always 1; metadata is conveyed via labels.",
[]string{"source_tag", "version", "commit"}, nil,
),
backendState: prometheus.NewDesc(
"maglev_backend_state",
@@ -223,7 +228,7 @@ func NewCollector(src StateSource, vpp VPPSource) *Collector {
// Describe implements prometheus.Collector.
func (c *Collector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.backendInfo
ch <- c.maglevInfo
ch <- c.backendState
ch <- c.backendHealth
ch <- c.backendEnabled
@@ -239,6 +244,9 @@ func (c *Collector) Describe(ch chan<- *prometheus.Desc) {
// Collect implements prometheus.Collector.
func (c *Collector) Collect(ch chan<- prometheus.Metric) {
ch <- prometheus.MustNewConstMetric(c.maglevInfo, prometheus.GaugeValue, 1.0,
c.sourceTag, c.version, c.commit)
states := []health.State{
health.StateUnknown,
health.StateUp,
@@ -255,11 +263,6 @@ func (c *Collector) Collect(ch chan<- prometheus.Metric) {
}
addr := info.Health.Address.String()
ch <- prometheus.MustNewConstMetric(
c.backendInfo, prometheus.GaugeValue, 1.0,
name, addr, info.HCName, info.SourceTag,
)
// One time-series per possible state; the current state is 1, rest 0.
for _, s := range states {
val := 0.0
@@ -355,9 +358,10 @@ func (c *Collector) Collect(ch chan<- prometheus.Metric) {
}
// Register registers all metrics with the given registry. vpp may be nil
// to disable VPP-related metrics.
func Register(reg prometheus.Registerer, src StateSource, vpp VPPSource) *Collector {
coll := NewCollector(src, vpp)
// to disable VPP-related metrics. version / commit are surfaced via
// maglev_info labels.
func Register(reg prometheus.Registerer, src StateSource, vpp VPPSource, sourceTag, version, commit string) *Collector {
coll := NewCollector(src, vpp, sourceTag, version, commit)
reg.MustRegister(coll)
reg.MustRegister(ProbeTotal)
reg.MustRegister(ProbeDuration)