Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12f17c0075 | |||
| d9a8ca6fb8 | |||
| 76fbe2eee0 | |||
| d2ee6d009e | |||
| dc7599f3ee | |||
| f16cf7cb14 | |||
| 86b265c2a9 |
@@ -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.0
|
||||
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
@@ -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
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 .
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
@@ -163,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"`
|
||||
@@ -299,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
|
||||
|
||||
@@ -131,9 +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
|
||||
|
||||
maglevInfo *prometheus.Desc
|
||||
backendState *prometheus.Desc
|
||||
backendHealth *prometheus.Desc
|
||||
backendEnabled *prometheus.Desc
|
||||
@@ -151,11 +155,19 @@ 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,
|
||||
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",
|
||||
"Current backend state (1 = active for the given state label).",
|
||||
@@ -216,6 +228,7 @@ func NewCollector(src StateSource, vpp VPPSource) *Collector {
|
||||
|
||||
// Describe implements prometheus.Collector.
|
||||
func (c *Collector) Describe(ch chan<- *prometheus.Desc) {
|
||||
ch <- c.maglevInfo
|
||||
ch <- c.backendState
|
||||
ch <- c.backendHealth
|
||||
ch <- c.backendEnabled
|
||||
@@ -231,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,
|
||||
@@ -342,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)
|
||||
|
||||
Reference in New Issue
Block a user