Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12f17c0075 | |||
| d9a8ca6fb8 | |||
| 76fbe2eee0 | |||
| d2ee6d009e | |||
| dc7599f3ee |
@@ -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
|
FRONTEND_WEB_DIST := cmd/frontend/web/dist/index.html
|
||||||
|
|
||||||
NATIVE_ARCH := $(shell go env GOARCH)
|
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)
|
COMMIT_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||||
DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
LDFLAGS := -X '$(MODULE)/cmd.version=$(VERSION)' \
|
LDFLAGS := -X '$(MODULE)/cmd.version=$(VERSION)' \
|
||||||
|
|||||||
+14
-33
@@ -2,42 +2,19 @@
|
|||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
const (
|
cli "git.ipng.ch/ipng/golang-cli"
|
||||||
ansiBlue = "\x1b[34m"
|
|
||||||
ansiRed = "\x1b[31m"
|
|
||||||
ansiReset = "\x1b[0m"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// colorEnabled is set by the -color flag in main.
|
|
||||||
var colorEnabled bool
|
|
||||||
|
|
||||||
// label wraps s in dark-blue ANSI when color output is enabled.
|
|
||||||
//
|
|
||||||
// Tabwriter caveat: tabwriter.Writer counts *bytes* per cell, not
|
|
||||||
// rendered columns. ANSI escape codes (`\x1b[34m…\x1b[0m`, 11 bytes)
|
|
||||||
// inflate a cell's apparent width without affecting what the terminal
|
|
||||||
// draws. Two things follow:
|
|
||||||
//
|
|
||||||
// 1. Key-value layouts where column 1 is *always* labelled and
|
|
||||||
// column 2 is *always* plain (e.g. `show vpp info`) stay aligned,
|
|
||||||
// because every row adds the same 11 bytes to column 1.
|
|
||||||
// 2. Multi-column tables where only the *header* row is labelled
|
|
||||||
// drift: the header cells each carry 11 extra bytes that the data
|
|
||||||
// rows don't, so data cells get over-padded. In those tables,
|
|
||||||
// leave the header plain (see runShowVPPLBCounters) and only use
|
|
||||||
// label() for labels that appear uniformly column-wise.
|
|
||||||
func label(s string) string {
|
|
||||||
if !colorEnabled {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return ansiBlue + s + ansiReset
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatError returns a user-friendly error string. gRPC status errors are
|
// formatError returns a user-friendly error string. gRPC status errors are
|
||||||
// unwrapped to show only the server's message (no "rpc error: code = ..."
|
// 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 {
|
func formatError(err error) string {
|
||||||
msg := err.Error()
|
msg := err.Error()
|
||||||
// google.golang.org/grpc/status errors format as:
|
// 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 {
|
if i := strings.Index(msg, " desc = "); i >= 0 {
|
||||||
msg = msg[i+len(" desc = "):]
|
msg = msg[i+len(" desc = "):]
|
||||||
}
|
}
|
||||||
if colorEnabled {
|
if cli.IsJSON() {
|
||||||
return ansiRed + msg + ansiReset
|
b, _ := json.Marshal(map[string]string{"error": msg})
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
if cli.ColorEnabled() {
|
||||||
|
return cli.Red + msg + cli.Reset
|
||||||
}
|
}
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|||||||
+195
-193
@@ -11,82 +11,88 @@ import (
|
|||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
cli "git.ipng.ch/ipng/golang-cli"
|
||||||
buildinfo "git.ipng.ch/ipng/vpp-maglev/cmd"
|
buildinfo "git.ipng.ch/ipng/vpp-maglev/cmd"
|
||||||
"git.ipng.ch/ipng/vpp-maglev/internal/grpcapi"
|
"git.ipng.ch/ipng/vpp-maglev/internal/grpcapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
const callTimeout = 10 * time.Second
|
const callTimeout = 10 * time.Second
|
||||||
|
|
||||||
// buildTree constructs the full command tree.
|
// node is the maglevc command tree node: a cli.Node specialized to the maglevd
|
||||||
func buildTree() *Node {
|
// gRPC client. The alias keeps the tree literals below concise (&node{...}
|
||||||
root := &Node{Word: "", Help: ""}
|
// instead of &cli.Node[grpcapi.MaglevClient]{...}).
|
||||||
|
type node = cli.Node[grpcapi.MaglevClient]
|
||||||
|
|
||||||
show := &Node{Word: "show", Help: "show information"}
|
// buildTree constructs the full command tree.
|
||||||
set := &Node{Word: "set", Help: "modify configuration"}
|
func buildTree() *node {
|
||||||
quit := &Node{Word: "quit", Help: "exit the shell", Run: runQuit}
|
root := &node{Word: "", Help: ""}
|
||||||
exit := &Node{Word: "exit", Help: "exit the shell", Run: runQuit}
|
|
||||||
|
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
|
// 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
|
// show frontends [<name>] — without name: list all, with name: show details
|
||||||
showFrontendName := &Node{
|
showFrontendName := &node{
|
||||||
Word: "<name>",
|
Word: "<name>",
|
||||||
Help: "Show details for a single frontend",
|
Help: "Show details for a single frontend",
|
||||||
Dynamic: dynFrontends,
|
Dynamic: dynFrontends,
|
||||||
Run: runShowFrontend,
|
Run: runShowFrontend,
|
||||||
}
|
}
|
||||||
showFrontends := &Node{
|
showFrontends := &node{
|
||||||
Word: "frontends",
|
Word: "frontends",
|
||||||
Help: "List all frontends",
|
Help: "List all frontends",
|
||||||
Run: runShowFrontends,
|
Run: runShowFrontends,
|
||||||
Children: []*Node{showFrontendName},
|
Children: []*node{showFrontendName},
|
||||||
}
|
}
|
||||||
|
|
||||||
// show backends [<name>] — without name: list all, with name: show details
|
// show backends [<name>] — without name: list all, with name: show details
|
||||||
showBackendName := &Node{
|
showBackendName := &node{
|
||||||
Word: "<name>",
|
Word: "<name>",
|
||||||
Help: "Show details for a single backend",
|
Help: "Show details for a single backend",
|
||||||
Dynamic: dynBackends,
|
Dynamic: dynBackends,
|
||||||
Run: runShowBackend,
|
Run: runShowBackend,
|
||||||
}
|
}
|
||||||
showBackends := &Node{
|
showBackends := &node{
|
||||||
Word: "backends",
|
Word: "backends",
|
||||||
Help: "List all backends",
|
Help: "List all backends",
|
||||||
Run: runShowBackends,
|
Run: runShowBackends,
|
||||||
Children: []*Node{showBackendName},
|
Children: []*node{showBackendName},
|
||||||
}
|
}
|
||||||
|
|
||||||
// show healthchecks [<name>] — without name: list all, with name: show details
|
// show healthchecks [<name>] — without name: list all, with name: show details
|
||||||
showHealthCheckName := &Node{
|
showHealthCheckName := &node{
|
||||||
Word: "<name>",
|
Word: "<name>",
|
||||||
Help: "Show details for a single health check",
|
Help: "Show details for a single health check",
|
||||||
Dynamic: dynHealthChecks,
|
Dynamic: dynHealthChecks,
|
||||||
Run: runShowHealthCheck,
|
Run: runShowHealthCheck,
|
||||||
}
|
}
|
||||||
showHealthChecks := &Node{
|
showHealthChecks := &node{
|
||||||
Word: "healthchecks",
|
Word: "healthchecks",
|
||||||
Help: "List all health checks",
|
Help: "List all health checks",
|
||||||
Run: runShowHealthChecks,
|
Run: runShowHealthChecks,
|
||||||
Children: []*Node{showHealthCheckName},
|
Children: []*node{showHealthCheckName},
|
||||||
}
|
}
|
||||||
|
|
||||||
// show vpp info / lb state / lb counters
|
// show vpp info / lb state / lb counters
|
||||||
showVPPInfo := &Node{Word: "info", Help: "Show VPP version, uptime, and connection status", Run: runShowVPPInfo}
|
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}
|
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}
|
showVPPLBCounters := &node{Word: "counters", Help: "Show VPP per-VIP and per-backend packet/byte counters (refreshed every ~5s server-side)", Run: runShowVPPLBCounters}
|
||||||
showVPPLB := &Node{
|
showVPPLB := &node{
|
||||||
Word: "lb",
|
Word: "lb",
|
||||||
Help: "VPP load-balancer information",
|
Help: "VPP load-balancer information",
|
||||||
Children: []*Node{showVPPLBState, showVPPLBCounters},
|
Children: []*node{showVPPLBState, showVPPLBCounters},
|
||||||
}
|
}
|
||||||
showVPP := &Node{
|
showVPP := &node{
|
||||||
Word: "vpp",
|
Word: "vpp",
|
||||||
Help: "VPP dataplane information",
|
Help: "VPP dataplane information",
|
||||||
Children: []*Node{showVPPInfo, showVPPLB},
|
Children: []*node{showVPPInfo, showVPPLB},
|
||||||
}
|
}
|
||||||
|
|
||||||
show.Children = []*Node{
|
show.Children = []*node{
|
||||||
showVersion,
|
showVersion,
|
||||||
showFrontends,
|
showFrontends,
|
||||||
showBackends,
|
showBackends,
|
||||||
@@ -95,20 +101,20 @@ func buildTree() *Node {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// set backend <name> pause|resume|disabled|enabled
|
// set backend <name> pause|resume|disabled|enabled
|
||||||
setPause := &Node{Word: "pause", Help: "pause health checking", Run: runPauseBackend}
|
setPause := &node{Word: "pause", Help: "pause health checking", Run: runPauseBackend}
|
||||||
setResume := &Node{Word: "resume", Help: "resume health checking", Run: runResumeBackend}
|
setResume := &node{Word: "resume", Help: "resume health checking", Run: runResumeBackend}
|
||||||
setDisabled := &Node{Word: "disable", Help: "disable backend (stop probing, remove from rotation)", Run: runDisableBackend}
|
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}
|
setEnabled := &node{Word: "enable", Help: "enable backend (resume probing)", Run: runEnableBackend}
|
||||||
setBackendName := &Node{
|
setBackendName := &node{
|
||||||
Word: "<name>",
|
Word: "<name>",
|
||||||
Help: "backend name",
|
Help: "backend name",
|
||||||
Dynamic: dynBackends,
|
Dynamic: dynBackends,
|
||||||
Children: []*Node{setPause, setResume, setDisabled, setEnabled},
|
Children: []*node{setPause, setResume, setDisabled, setEnabled},
|
||||||
}
|
}
|
||||||
setBackend := &Node{
|
setBackend := &node{
|
||||||
Word: "backend",
|
Word: "backend",
|
||||||
Help: "modify a backend",
|
Help: "modify a backend",
|
||||||
Children: []*Node{setBackendName},
|
Children: []*node{setBackendName},
|
||||||
}
|
}
|
||||||
// set frontend <name> pool <pool> backend <name> weight <0-100> [flush]
|
// 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
|
// args, so the literal "flush" keyword isn't visible in the arg
|
||||||
// list. We use two distinct Run functions to distinguish the two
|
// list. We use two distinct Run functions to distinguish the two
|
||||||
// leaf paths instead — both share the same underlying helper.
|
// leaf paths instead — both share the same underlying helper.
|
||||||
setWeightFlush := &Node{
|
setWeightFlush := &node{
|
||||||
Word: "flush",
|
Word: "flush",
|
||||||
Help: "also drop VPP's flow table for this backend (otherwise only the new-buckets map is updated)",
|
Help: "also drop VPP's flow table for this backend (otherwise only the new-buckets map is updated)",
|
||||||
Run: runSetFrontendPoolBackendWeightFlush,
|
Run: runSetFrontendPoolBackendWeightFlush,
|
||||||
}
|
}
|
||||||
setWeightValue := &Node{
|
setWeightValue := &node{
|
||||||
Word: "<weight>",
|
Word: "<weight>",
|
||||||
Help: "Set weight of a backend in a pool (0-100)",
|
Help: "Set weight of a backend in a pool (0-100)",
|
||||||
Dynamic: dynNone, // accepts any integer; no tab-completion candidates
|
Dynamic: dynNone, // accepts any integer; no tab-completion candidates
|
||||||
Run: runSetFrontendPoolBackendWeight,
|
Run: runSetFrontendPoolBackendWeight,
|
||||||
Children: []*Node{setWeightFlush},
|
Children: []*node{setWeightFlush},
|
||||||
}
|
}
|
||||||
setFrontendPoolBackendWeight := &Node{Word: "weight", Help: "set backend weight in pool", Children: []*Node{setWeightValue}}
|
setFrontendPoolBackendWeight := &node{Word: "weight", Help: "set backend weight in pool", Children: []*node{setWeightValue}}
|
||||||
setFrontendPoolBackendName := &Node{
|
setFrontendPoolBackendName := &node{
|
||||||
Word: "<backend>",
|
Word: "<backend>",
|
||||||
Help: "backend name",
|
Help: "backend name",
|
||||||
Dynamic: dynBackends,
|
Dynamic: dynBackends,
|
||||||
Children: []*Node{setFrontendPoolBackendWeight},
|
Children: []*node{setFrontendPoolBackendWeight},
|
||||||
}
|
}
|
||||||
setFrontendPoolBackend := &Node{Word: "backend", Help: "select a backend", Children: []*Node{setFrontendPoolBackendName}}
|
setFrontendPoolBackend := &node{Word: "backend", Help: "select a backend", Children: []*node{setFrontendPoolBackendName}}
|
||||||
setFrontendPoolName := &Node{
|
setFrontendPoolName := &node{
|
||||||
Word: "<pool>",
|
Word: "<pool>",
|
||||||
Help: "pool name",
|
Help: "pool name",
|
||||||
Dynamic: dynNone, // pool names aren't listed via gRPC; accepts any input
|
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}}
|
setFrontendPool := &node{Word: "pool", Help: "select a pool", Children: []*node{setFrontendPoolName}}
|
||||||
setFrontendName := &Node{
|
setFrontendName := &node{
|
||||||
Word: "<name>",
|
Word: "<name>",
|
||||||
Help: "frontend name",
|
Help: "frontend name",
|
||||||
Dynamic: dynFrontends,
|
Dynamic: dynFrontends,
|
||||||
Children: []*Node{setFrontendPool},
|
Children: []*node{setFrontendPool},
|
||||||
}
|
}
|
||||||
setFrontend := &Node{
|
setFrontend := &node{
|
||||||
Word: "frontend",
|
Word: "frontend",
|
||||||
Help: "modify a 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]
|
// watch events [num <n>] [log [level <level>]] [backend] [frontend]
|
||||||
//
|
//
|
||||||
// All tokens after 'events' are captured as args via a self-referencing slot
|
// All tokens after 'events' are captured as args via a self-referencing slot
|
||||||
// node. This lets runWatchEvents parse the optional flags manually while still
|
// node. This lets runWatchEvents parse the optional flags manually while still
|
||||||
// providing tab-completion through the dynamic enumerator.
|
// providing tab-completion through the dynamic enumerator.
|
||||||
watchEventsOptSlot := &Node{
|
watchEventsOptSlot := &node{
|
||||||
Word: "<opt>",
|
Word: "<opt>",
|
||||||
Help: "Stream events with options",
|
Help: "Stream events with options",
|
||||||
Dynamic: dynWatchEventOpts,
|
Dynamic: dynWatchEventOpts,
|
||||||
Run: runWatchEvents,
|
Run: runWatchEvents,
|
||||||
}
|
}
|
||||||
watchEventsOptSlot.Children = []*Node{watchEventsOptSlot}
|
watchEventsOptSlot.Children = []*node{watchEventsOptSlot}
|
||||||
|
|
||||||
watchEvents := &Node{
|
watchEvents := &node{
|
||||||
Word: "events",
|
Word: "events",
|
||||||
Help: "stream events (press any key or Ctrl-C to stop)",
|
Help: "stream events (press any key or Ctrl-C to stop)",
|
||||||
Run: runWatchEvents,
|
Run: runWatchEvents,
|
||||||
Children: []*Node{watchEventsOptSlot},
|
Children: []*node{watchEventsOptSlot},
|
||||||
}
|
}
|
||||||
watch := &Node{
|
watch := &node{
|
||||||
Word: "watch",
|
Word: "watch",
|
||||||
Help: "watch live event streams",
|
Help: "watch live event streams",
|
||||||
Children: []*Node{watchEvents},
|
Children: []*node{watchEvents},
|
||||||
}
|
}
|
||||||
|
|
||||||
// config check / reload
|
// config check / reload
|
||||||
configCheck := &Node{Word: "check", Help: "Check configuration file", Run: runConfigCheck}
|
configCheck := &node{Word: "check", Help: "Check configuration file", Run: runConfigCheck}
|
||||||
configReload := &Node{Word: "reload", Help: "Check and reload configuration", Run: runConfigReload}
|
configReload := &node{Word: "reload", Help: "Check and reload configuration", Run: runConfigReload}
|
||||||
configNode := &Node{
|
configNode := &node{
|
||||||
Word: "config",
|
Word: "config",
|
||||||
Help: "configuration commands",
|
Help: "configuration commands",
|
||||||
Children: []*Node{configCheck, configReload},
|
Children: []*node{configCheck, configReload},
|
||||||
}
|
}
|
||||||
|
|
||||||
// sync vpp lb state [<name>]
|
// sync vpp lb state [<name>]
|
||||||
//
|
//
|
||||||
// Without a name: run SyncLBStateAll (may remove stale VIPs).
|
// Without a name: run SyncLBStateAll (may remove stale VIPs).
|
||||||
// With a name: run SyncLBStateVIP(name) for just that frontend (no removals).
|
// With a name: run SyncLBStateVIP(name) for just that frontend (no removals).
|
||||||
syncVPPLBStateName := &Node{
|
syncVPPLBStateName := &node{
|
||||||
Word: "<name>",
|
Word: "<name>",
|
||||||
Help: "Sync a single frontend's VIP to VPP",
|
Help: "Sync a single frontend's VIP to VPP",
|
||||||
Dynamic: dynFrontends,
|
Dynamic: dynFrontends,
|
||||||
Run: runSyncVPPLBState,
|
Run: runSyncVPPLBState,
|
||||||
}
|
}
|
||||||
syncVPPLBState := &Node{
|
syncVPPLBState := &node{
|
||||||
Word: "state",
|
Word: "state",
|
||||||
Help: "Sync the VPP load-balancer dataplane from the running config",
|
Help: "Sync the VPP load-balancer dataplane from the running config",
|
||||||
Run: runSyncVPPLBState,
|
Run: runSyncVPPLBState,
|
||||||
Children: []*Node{syncVPPLBStateName},
|
Children: []*node{syncVPPLBStateName},
|
||||||
}
|
}
|
||||||
syncVPPLB := &Node{
|
syncVPPLB := &node{
|
||||||
Word: "lb",
|
Word: "lb",
|
||||||
Help: "VPP load-balancer sync commands",
|
Help: "VPP load-balancer sync commands",
|
||||||
Children: []*Node{syncVPPLBState},
|
Children: []*node{syncVPPLBState},
|
||||||
}
|
}
|
||||||
syncVPP := &Node{
|
syncVPP := &node{
|
||||||
Word: "vpp",
|
Word: "vpp",
|
||||||
Help: "VPP dataplane sync commands",
|
Help: "VPP dataplane sync commands",
|
||||||
Children: []*Node{syncVPPLB},
|
Children: []*node{syncVPPLB},
|
||||||
}
|
}
|
||||||
syncNode := &Node{
|
syncNode := &node{
|
||||||
Word: "sync",
|
Word: "sync",
|
||||||
Help: "Reconcile dataplane state from the running config",
|
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
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- dynamic enumerators ---------------------------------------------------
|
// ---- 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{})
|
resp, err := client.ListFrontends(ctx, &grpcapi.ListFrontendsRequest{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -237,7 +246,7 @@ func dynFrontends(ctx context.Context, client grpcapi.MaglevClient) []string {
|
|||||||
return resp.FrontendNames
|
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{})
|
resp, err := client.ListBackends(ctx, &grpcapi.ListBackendsRequest{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -245,7 +254,7 @@ func dynBackends(ctx context.Context, client grpcapi.MaglevClient) []string {
|
|||||||
return resp.BackendNames
|
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{})
|
resp, err := client.ListHealthChecks(ctx, &grpcapi.ListHealthChecksRequest{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 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
|
// dynNone marks a slot node that accepts any input but provides no
|
||||||
// tab-completion candidates (e.g. a pool name or numeric weight value).
|
// 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 ---------------------------------------------------------
|
// ---- run functions ---------------------------------------------------------
|
||||||
|
|
||||||
@@ -266,19 +275,22 @@ func runShowVPPInfo(ctx context.Context, client grpcapi.MaglevClient, _ []string
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if cli.IsJSON() {
|
||||||
|
return emitProto(info)
|
||||||
|
}
|
||||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
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", cli.Label("version"), info.Version)
|
||||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("build-date"), info.BuildDate)
|
_, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("build-date"), info.BuildDate)
|
||||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("build-dir"), info.BuildDirectory)
|
_, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("build-dir"), info.BuildDirectory)
|
||||||
_, _ = fmt.Fprintf(w, "%s\t%d\n", label("vpp-pid"), info.Pid)
|
_, _ = fmt.Fprintf(w, "%s\t%d\n", cli.Label("vpp-pid"), info.Pid)
|
||||||
if info.BoottimeNs > 0 {
|
if info.BoottimeNs > 0 {
|
||||||
bootTime := time.Unix(0, info.BoottimeNs)
|
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"),
|
bootTime.Format("2006-01-02 15:04:05"),
|
||||||
formatDuration(time.Since(bootTime)))
|
formatDuration(time.Since(bootTime)))
|
||||||
}
|
}
|
||||||
connTime := time.Unix(0, info.ConnecttimeNs)
|
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"),
|
connTime.Format("2006-01-02 15:04:05"),
|
||||||
formatDuration(time.Since(connTime)))
|
formatDuration(time.Since(connTime)))
|
||||||
return w.Flush()
|
return w.Flush()
|
||||||
@@ -291,24 +303,27 @@ func runShowVPPLBState(ctx context.Context, client grpcapi.MaglevClient, _ []str
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if cli.IsJSON() {
|
||||||
|
return emitProto(state)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- global config ----
|
// ---- global config ----
|
||||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
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 != "" {
|
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 != "" {
|
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%d\n", cli.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%ds\n", cli.Label("flow-timeout"), state.Conf.FlowTimeout)
|
||||||
if err := w.Flush(); err != nil {
|
if err := w.Flush(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(state.Vips) == 0 {
|
if len(state.Vips) == 0 {
|
||||||
fmt.Println(label("vips") + " (none)")
|
fmt.Println(cli.Label("vips") + " (none)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,21 +331,21 @@ func runShowVPPLBState(ctx context.Context, client grpcapi.MaglevClient, _ []str
|
|||||||
for _, v := range state.Vips {
|
for _, v := range state.Vips {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
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", cli.Label("vip"), stripHostMask(v.Prefix))
|
||||||
_, _ = fmt.Fprintf(w, " %s\t%s\n", label("protocol"), protoString(v.Protocol))
|
_, _ = fmt.Fprintf(w, " %s\t%s\n", cli.Label("protocol"), protoString(v.Protocol))
|
||||||
_, _ = fmt.Fprintf(w, " %s\t%d\n", label("port"), v.Port)
|
_, _ = fmt.Fprintf(w, " %s\t%d\n", cli.Label("port"), v.Port)
|
||||||
_, _ = fmt.Fprintf(w, " %s\t%s\n", label("encap"), v.Encap)
|
_, _ = fmt.Fprintf(w, " %s\t%s\n", cli.Label("encap"), v.Encap)
|
||||||
_, _ = fmt.Fprintf(w, " %s\t%t\n", label("src-ip-sticky"), v.SrcIpSticky)
|
_, _ = fmt.Fprintf(w, " %s\t%t\n", cli.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", cli.Label("flow-table-length"), v.FlowTableLength)
|
||||||
_, _ = fmt.Fprintf(w, " %s\t%d\n", label("application-servers"), len(v.ApplicationServers))
|
_, _ = fmt.Fprintf(w, " %s\t%d\n", cli.Label("application-servers"), len(v.ApplicationServers))
|
||||||
if err := w.Flush(); err != nil {
|
if err := w.Flush(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, a := range v.ApplicationServers {
|
for _, a := range v.ApplicationServers {
|
||||||
fmt.Printf(" %s %s %s %d %s %d\n",
|
fmt.Printf(" %s %s %s %d %s %d\n",
|
||||||
label("address"), a.Address,
|
cli.Label("address"), a.Address,
|
||||||
label("weight"), a.Weight,
|
cli.Label("weight"), a.Weight,
|
||||||
label("flow-table-buckets"), a.NumBuckets)
|
cli.Label("flow-table-buckets"), a.NumBuckets)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -348,6 +363,9 @@ func runShowVPPLBCounters(ctx context.Context, client grpcapi.MaglevClient, _ []
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if cli.IsJSON() {
|
||||||
|
return emitProto(resp)
|
||||||
|
}
|
||||||
|
|
||||||
if len(resp.Vips) == 0 {
|
if len(resp.Vips) == 0 {
|
||||||
fmt.Println("(no counters — VPP disconnected or scrape pending)")
|
fmt.Println("(no counters — VPP disconnected or scrape pending)")
|
||||||
@@ -356,15 +374,15 @@ func runShowVPPLBCounters(ctx context.Context, client grpcapi.MaglevClient, _ []
|
|||||||
|
|
||||||
// ---- frontend-counters ----
|
// ---- 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
|
// counts bytes (not rendered width), so wrapping a header cell in
|
||||||
// ANSI escape codes inflates its apparent width by ~11 bytes and
|
// ANSI escape codes inflates its apparent width by ~11 bytes and
|
||||||
// the data rows below — which are plain numeric strings — end up
|
// 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
|
// 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
|
// commands do but this table can't (we're not about to colourise
|
||||||
// every packet count).
|
// every packet count).
|
||||||
fmt.Println(label("frontend-counters"))
|
fmt.Println(cli.Label("frontend-counters"))
|
||||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
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")
|
_, _ = fmt.Fprintf(w, " vip\tproto\tport\tfirst\tnext\tuntracked\tno-server\tfib-packets\tfib-bytes\n")
|
||||||
for _, v := range resp.Vips {
|
for _, v := range resp.Vips {
|
||||||
@@ -411,25 +429,25 @@ func runSyncVPPLBState(ctx context.Context, client grpcapi.MaglevClient, args []
|
|||||||
name := args[0]
|
name := args[0]
|
||||||
req.FrontendName = &name
|
req.FrontendName = &name
|
||||||
}
|
}
|
||||||
if _, err := client.SyncVPPLBState(ctx, req); err != nil {
|
_, err := client.SyncVPPLBState(ctx, req)
|
||||||
return err
|
return err
|
||||||
}
|
|
||||||
if req.FrontendName != nil {
|
|
||||||
fmt.Printf("synced frontend %q to VPP\n", *req.FrontendName)
|
|
||||||
} else {
|
|
||||||
fmt.Println("synced full LB state to VPP")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runShowVersion(_ context.Context, _ grpcapi.MaglevClient, _ []string) error {
|
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",
|
fmt.Printf("maglevc %s (commit %s, built %s)\n",
|
||||||
buildinfo.Version(), buildinfo.Commit(), buildinfo.Date())
|
buildinfo.Version(), buildinfo.Commit(), buildinfo.Date())
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runQuit(_ context.Context, _ grpcapi.MaglevClient, _ []string) error {
|
func runQuit(_ context.Context, _ grpcapi.MaglevClient, _ []string) error {
|
||||||
return errQuit
|
return cli.ErrQuit
|
||||||
}
|
}
|
||||||
|
|
||||||
func runShowFrontends(ctx context.Context, client grpcapi.MaglevClient, _ []string) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if cli.IsJSON() {
|
||||||
|
return emitProto(resp)
|
||||||
|
}
|
||||||
for _, name := range resp.FrontendNames {
|
for _, name := range resp.FrontendNames {
|
||||||
fmt.Println(name)
|
fmt.Println(name)
|
||||||
}
|
}
|
||||||
@@ -455,19 +476,22 @@ func runShowFrontend(ctx context.Context, client grpcapi.MaglevClient, args []st
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if cli.IsJSON() {
|
||||||
|
return emitProto(info)
|
||||||
|
}
|
||||||
|
|
||||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
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", cli.Label("name"), info.Name)
|
||||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("address"), info.Address)
|
_, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("address"), info.Address)
|
||||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("protocol"), info.Protocol)
|
_, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("protocol"), info.Protocol)
|
||||||
_, _ = fmt.Fprintf(w, "%s\t%d\n", label("port"), info.Port)
|
_, _ = fmt.Fprintf(w, "%s\t%d\n", cli.Label("port"), info.Port)
|
||||||
_, _ = fmt.Fprintf(w, "%s\t%t\n", label("src-ip-sticky"), info.SrcIpSticky)
|
_, _ = fmt.Fprintf(w, "%s\t%t\n", cli.Label("src-ip-sticky"), info.SrcIpSticky)
|
||||||
_, _ = fmt.Fprintf(w, "%s\t%t\n", label("flush-on-down"), info.FlushOnDown)
|
_, _ = fmt.Fprintf(w, "%s\t%t\n", cli.Label("flush-on-down"), info.FlushOnDown)
|
||||||
if info.Description != "" {
|
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 {
|
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 {
|
if err := w.Flush(); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -484,7 +508,7 @@ func runShowFrontend(ctx context.Context, client grpcapi.MaglevClient, args []st
|
|||||||
|
|
||||||
for _, pool := range info.Pools {
|
for _, pool := range info.Pools {
|
||||||
namePad := strings.Repeat(" ", poolLblWidth-len("name"))
|
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 {
|
for i, pb := range pool.Backends {
|
||||||
beInfo, beErr := client.GetBackend(ctx, &grpcapi.GetBackendRequest{Name: pb.Name})
|
beInfo, beErr := client.GetBackend(ctx, &grpcapi.GetBackendRequest{Name: pb.Name})
|
||||||
suffix := ""
|
suffix := ""
|
||||||
@@ -496,11 +520,11 @@ func runShowFrontend(ctx context.Context, client grpcapi.MaglevClient, args []st
|
|||||||
// after pool-failover logic). Format matches the VPP-style
|
// after pool-failover logic). Format matches the VPP-style
|
||||||
// key-value line so robot tests can parse it with a regex.
|
// key-value line so robot tests can parse it with a regex.
|
||||||
metaStr := fmt.Sprintf(" %s %d %s %d",
|
metaStr := fmt.Sprintf(" %s %d %s %d",
|
||||||
label("weight"), pb.Weight,
|
cli.Label("weight"), pb.Weight,
|
||||||
label("effective"), pb.EffectiveWeight)
|
cli.Label("effective"), pb.EffectiveWeight)
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
bePad := strings.Repeat(" ", poolLblWidth-len("backends"))
|
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 {
|
} else {
|
||||||
fmt.Printf("%s%s%s%s\n", contIndent, pb.Name, metaStr, suffix)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if cli.IsJSON() {
|
||||||
|
return emitProto(resp)
|
||||||
|
}
|
||||||
for _, name := range resp.BackendNames {
|
for _, name := range resp.BackendNames {
|
||||||
fmt.Println(name)
|
fmt.Println(name)
|
||||||
}
|
}
|
||||||
@@ -532,27 +559,30 @@ func runShowBackend(ctx context.Context, client grpcapi.MaglevClient, args []str
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if cli.IsJSON() {
|
||||||
|
return emitProto(info)
|
||||||
|
}
|
||||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
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", cli.Label("name"), info.Name)
|
||||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("address"), info.Address)
|
_, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("address"), info.Address)
|
||||||
stateDur := ""
|
stateDur := ""
|
||||||
if len(info.Transitions) > 0 {
|
if len(info.Transitions) > 0 {
|
||||||
since := time.Since(time.Unix(0, info.Transitions[0].AtUnixNs))
|
since := time.Since(time.Unix(0, info.Transitions[0].AtUnixNs))
|
||||||
stateDur = " for " + formatDuration(since)
|
stateDur = " for " + formatDuration(since)
|
||||||
}
|
}
|
||||||
_, _ = fmt.Fprintf(w, "%s\t%s%s\n", label("state"), info.State, stateDur)
|
_, _ = fmt.Fprintf(w, "%s\t%s%s\n", cli.Label("state"), info.State, stateDur)
|
||||||
_, _ = fmt.Fprintf(w, "%s\t%v\n", label("enabled"), info.Enabled)
|
_, _ = fmt.Fprintf(w, "%s\t%v\n", cli.Label("enabled"), info.Enabled)
|
||||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("healthcheck"), info.Healthcheck)
|
_, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("healthcheck"), info.Healthcheck)
|
||||||
for i, t := range info.Transitions {
|
for i, t := range info.Transitions {
|
||||||
ts := time.Unix(0, t.AtUnixNs)
|
ts := time.Unix(0, t.AtUnixNs)
|
||||||
var lbl string
|
var lbl string
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
lbl = label("transitions")
|
lbl = cli.Label("transitions")
|
||||||
} else {
|
} else {
|
||||||
// Pad to same visible width as "transitions" and wrap through
|
// 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).
|
// 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",
|
_, _ = fmt.Fprintf(w, "%s\t%s → %s\t%s\t%s\n",
|
||||||
lbl,
|
lbl,
|
||||||
@@ -571,6 +601,9 @@ func runShowHealthChecks(ctx context.Context, client grpcapi.MaglevClient, _ []s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if cli.IsJSON() {
|
||||||
|
return emitProto(resp)
|
||||||
|
}
|
||||||
for _, name := range resp.Names {
|
for _, name := range resp.Names {
|
||||||
fmt.Println(name)
|
fmt.Println(name)
|
||||||
}
|
}
|
||||||
@@ -587,42 +620,45 @@ func runShowHealthCheck(ctx context.Context, client grpcapi.MaglevClient, args [
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
if cli.IsJSON() {
|
||||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("name"), info.Name)
|
return emitProto(info)
|
||||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("type"), info.Type)
|
|
||||||
if info.Port > 0 {
|
|
||||||
_, _ = fmt.Fprintf(w, "%s\t%d\n", label("port"), info.Port)
|
|
||||||
}
|
}
|
||||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", label("interval"), time.Duration(info.IntervalNs))
|
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 {
|
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 {
|
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%s\n", cli.Label("timeout"), time.Duration(info.TimeoutNs))
|
||||||
_, _ = fmt.Fprintf(w, "%s\t%d\n", label("rise"), info.Rise)
|
_, _ = fmt.Fprintf(w, "%s\t%d\n", cli.Label("rise"), info.Rise)
|
||||||
_, _ = fmt.Fprintf(w, "%s\t%d\n", label("fall"), info.Fall)
|
_, _ = fmt.Fprintf(w, "%s\t%d\n", cli.Label("fall"), info.Fall)
|
||||||
if info.ProbeIpv4Src != "" {
|
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 != "" {
|
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 {
|
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 != "" {
|
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 != "" {
|
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 {
|
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 != "" {
|
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()
|
return w.Flush()
|
||||||
@@ -634,12 +670,8 @@ func runPauseBackend(ctx context.Context, client grpcapi.MaglevClient, args []st
|
|||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
info, err := client.PauseBackend(ctx, &grpcapi.BackendRequest{Name: args[0]})
|
_, err := client.PauseBackend(ctx, &grpcapi.BackendRequest{Name: args[0]})
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("%s: setting state to '%s'\n", info.Name, info.State)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runResumeBackend(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
|
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)
|
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
info, err := client.ResumeBackend(ctx, &grpcapi.BackendRequest{Name: args[0]})
|
_, err := client.ResumeBackend(ctx, &grpcapi.BackendRequest{Name: args[0]})
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("%s: setting state to '%s'\n", info.Name, info.State)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runSetFrontendPoolBackendWeight(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
|
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)
|
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
info, err := client.SetFrontendPoolBackendWeight(ctx, &grpcapi.SetWeightRequest{
|
_, err = client.SetFrontendPoolBackendWeight(ctx, &grpcapi.SetWeightRequest{
|
||||||
Frontend: frontendName,
|
Frontend: frontendName,
|
||||||
Pool: poolName,
|
Pool: poolName,
|
||||||
Backend: backendName,
|
Backend: backendName,
|
||||||
Weight: int32(weight),
|
Weight: int32(weight),
|
||||||
Flush: flush,
|
Flush: flush,
|
||||||
})
|
})
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Print the updated pool so the user can confirm the new weight.
|
|
||||||
for _, pool := range info.Pools {
|
|
||||||
if pool.Name != poolName {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, pb := range pool.Backends {
|
|
||||||
if pb.Name == backendName {
|
|
||||||
flushNote := ""
|
|
||||||
if flush {
|
|
||||||
flushNote = " (flushed)"
|
|
||||||
}
|
|
||||||
fmt.Printf("%s pool %s backend %s: weight set to %d%s\n",
|
|
||||||
info.Name, pool.Name, pb.Name, pb.Weight, flushNote)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runEnableBackend(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
|
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)
|
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
info, err := client.EnableBackend(ctx, &grpcapi.BackendRequest{Name: args[0]})
|
_, err := client.EnableBackend(ctx, &grpcapi.BackendRequest{Name: args[0]})
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("%s: enabled, state is '%s'\n", info.Name, info.State)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runDisableBackend(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
|
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)
|
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
info, err := client.DisableBackend(ctx, &grpcapi.BackendRequest{Name: args[0]})
|
_, err := client.DisableBackend(ctx, &grpcapi.BackendRequest{Name: args[0]})
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("%s: disabled, state is '%s'\n", info.Name, info.State)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runConfigCheck(ctx context.Context, client grpcapi.MaglevClient, _ []string) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if cli.IsJSON() {
|
||||||
|
return emitProto(resp)
|
||||||
|
}
|
||||||
if resp.Ok {
|
if resp.Ok {
|
||||||
fmt.Println("config ok")
|
fmt.Println("config ok")
|
||||||
return nil
|
return nil
|
||||||
@@ -758,7 +761,6 @@ func runConfigReload(ctx context.Context, client grpcapi.MaglevClient, _ []strin
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if resp.Ok {
|
if resp.Ok {
|
||||||
fmt.Println("config reloaded")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if resp.ParseError != "" {
|
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
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"flag"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
|
||||||
|
cli "git.ipng.ch/ipng/golang-cli"
|
||||||
buildinfo "git.ipng.ch/ipng/vpp-maglev/cmd"
|
buildinfo "git.ipng.ch/ipng/vpp-maglev/cmd"
|
||||||
"git.ipng.ch/ipng/vpp-maglev/internal/grpcapi"
|
"git.ipng.ch/ipng/vpp-maglev/internal/grpcapi"
|
||||||
"git.ipng.ch/ipng/vpp-maglev/internal/netutil"
|
"git.ipng.ch/ipng/vpp-maglev/internal/netutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultGRPCPort is the maglevd gRPC port (mirrors the server's
|
// defaultGRPCPort is the maglevd gRPC port (mirrors the server's -grpc-addr
|
||||||
// -grpc-addr default in cmd/server/main.go). Used when -server is given
|
// default), used when -server is given without an explicit ":<port>".
|
||||||
// without an explicit ":<port>" so operators can type "--server chbtl2"
|
|
||||||
// instead of "--server chbtl2:9090".
|
|
||||||
const defaultGRPCPort = "9090"
|
const defaultGRPCPort = "9090"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := run(); err != nil {
|
(&cli.App[grpcapi.MaglevClient]{
|
||||||
fmt.Fprintf(os.Stderr, "%s\n", formatError(err))
|
Name: "maglevc",
|
||||||
os.Exit(1)
|
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 {
|
// connect dials maglevd and returns the gRPC client. App resolves -server (env
|
||||||
defaultServer := "localhost:9090"
|
// MAGLEV_SERVER, default localhost:9090) and hands us the raw address; we ensure
|
||||||
if v := os.Getenv("MAGLEV_SERVER"); v != "" {
|
// a port and dial insecure, mirroring maglevd's default transport.
|
||||||
defaultServer = v
|
func connect(_ context.Context, server string) (grpcapi.MaglevClient, func(), error) {
|
||||||
}
|
addr := netutil.EnsurePort(server, defaultGRPCPort)
|
||||||
serverAddr := flag.String("server", defaultServer, "maglev server address (env: MAGLEV_SERVER)")
|
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||||
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()))
|
|
||||||
if err != nil {
|
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() }()
|
return grpcapi.NewMaglevClient(conn), func() { _ = conn.Close() }, nil
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
cli "git.ipng.ch/ipng/golang-cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExpandPathsRoot(t *testing.T) {
|
func TestExpandPathsRoot(t *testing.T) {
|
||||||
root := buildTree()
|
root := buildTree()
|
||||||
lines := expandPaths(root, "", make(map[*Node]bool))
|
lines := cli.ExpandPaths(root, "")
|
||||||
|
|
||||||
// Should include well-known leaf paths.
|
// Should include well-known leaf paths.
|
||||||
want := []string{
|
want := []string{
|
||||||
@@ -41,7 +43,7 @@ func TestExpandPathsRoot(t *testing.T) {
|
|||||||
|
|
||||||
paths := make(map[string]bool, len(lines))
|
paths := make(map[string]bool, len(lines))
|
||||||
for _, l := range lines {
|
for _, l := range lines {
|
||||||
paths[l.path] = true
|
paths[l.Path] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, w := range want {
|
for _, w := range want {
|
||||||
@@ -53,15 +55,15 @@ func TestExpandPathsRoot(t *testing.T) {
|
|||||||
|
|
||||||
func TestExpandPathsShow(t *testing.T) {
|
func TestExpandPathsShow(t *testing.T) {
|
||||||
root := buildTree()
|
root := buildTree()
|
||||||
showNode, _, _ := Walk(root, []string{"show"})
|
showNode, _, _ := cli.Walk(root, []string{"show"})
|
||||||
lines := expandPaths(showNode, "show", make(map[*Node]bool))
|
lines := cli.ExpandPaths(showNode, "show")
|
||||||
|
|
||||||
for _, l := range lines {
|
for _, l := range lines {
|
||||||
if !strings.HasPrefix(l.path, "show ") {
|
if !strings.HasPrefix(l.Path, "show ") {
|
||||||
t.Errorf("unexpected path %q: should start with 'show '", l.path)
|
t.Errorf("unexpected path %q: should start with 'show '", l.Path)
|
||||||
}
|
}
|
||||||
if l.help == "" {
|
if l.Help == "" {
|
||||||
t.Errorf("path %q has empty help", l.path)
|
t.Errorf("path %q has empty help", l.Path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// version, frontends, frontends <name>, backends, backends <name>,
|
// version, frontends, frontends <name>, backends, backends <name>,
|
||||||
@@ -75,8 +77,8 @@ func TestExpandPathsShow(t *testing.T) {
|
|||||||
func TestExpandPathsNoCycles(t *testing.T) {
|
func TestExpandPathsNoCycles(t *testing.T) {
|
||||||
root := buildTree()
|
root := buildTree()
|
||||||
// watch events has a self-referencing slot; expandPaths must terminate.
|
// watch events has a self-referencing slot; expandPaths must terminate.
|
||||||
watchEvents, _, _ := Walk(root, []string{"watch", "events"})
|
watchEvents, _, _ := cli.Walk(root, []string{"watch", "events"})
|
||||||
lines := expandPaths(watchEvents, "watch events", make(map[*Node]bool))
|
lines := cli.ExpandPaths(watchEvents, "watch events")
|
||||||
|
|
||||||
// Should produce exactly 2 lines: "watch events" and "watch events <opt>".
|
// Should produce exactly 2 lines: "watch events" and "watch events <opt>".
|
||||||
if len(lines) != 2 {
|
if len(lines) != 2 {
|
||||||
@@ -87,8 +89,8 @@ func TestExpandPathsNoCycles(t *testing.T) {
|
|||||||
func TestExpandPathsSetBackendName(t *testing.T) {
|
func TestExpandPathsSetBackendName(t *testing.T) {
|
||||||
root := buildTree()
|
root := buildTree()
|
||||||
// Walk to the name slot so displayPrefix carries the actual arg.
|
// Walk to the name slot so displayPrefix carries the actual arg.
|
||||||
node, _, _ := Walk(root, []string{"set", "backend", "mybackend"})
|
node, _, _ := cli.Walk(root, []string{"set", "backend", "mybackend"})
|
||||||
lines := expandPaths(node, "set backend mybackend", make(map[*Node]bool))
|
lines := cli.ExpandPaths(node, "set backend mybackend")
|
||||||
|
|
||||||
want := []string{
|
want := []string{
|
||||||
"set backend mybackend pause",
|
"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)
|
t.Fatalf("expected %d lines, got %d: %v", len(want), len(lines), lines)
|
||||||
}
|
}
|
||||||
for i, w := range want {
|
for i, w := range want {
|
||||||
if lines[i].path != w {
|
if lines[i].Path != w {
|
||||||
t.Errorf("line %d: got %q, want %q", i, 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()
|
root := buildTree()
|
||||||
|
|
||||||
// "sh ba" → show backends (list all) via prefix matching.
|
// "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 {
|
if node.Run == nil {
|
||||||
t.Fatal("'sh ba' did not reach a Run node")
|
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.
|
// "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 {
|
if node.Run == nil {
|
||||||
t.Fatal("'sh ba nginx0' did not reach a Run node")
|
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).
|
// "sh fr" → show frontends (list all).
|
||||||
node, _, _ = Walk(root, []string{"sh", "fr"})
|
node, _, _ = cli.Walk(root, []string{"sh", "fr"})
|
||||||
if node.Run == nil {
|
if node.Run == nil {
|
||||||
t.Fatal("'sh fr' did not reach a Run node")
|
t.Fatal("'sh fr' did not reach a Run node")
|
||||||
}
|
}
|
||||||
|
|
||||||
// "sh he icmp" → show healthchecks icmp (get specific).
|
// "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 {
|
if node.Run == nil {
|
||||||
t.Fatal("'sh he icmp' did not reach a Run node")
|
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
|
// A bare unknown word leaves every token unconsumed and anchors
|
||||||
// the returned node at the root — callers must treat this as
|
// the returned node at the root — callers must treat this as
|
||||||
// "unknown command" rather than silently showing the whole tree.
|
// "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 {
|
if node != root {
|
||||||
t.Errorf("'foo' should leave walk at root, got %q", node.Word)
|
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
|
// Partial consumption: "show" matches but "bogus" doesn't. The
|
||||||
// returned remaining is the first unmatched token onwards so the
|
// returned remaining is the first unmatched token onwards so the
|
||||||
// caller can point at exactly what was wrong.
|
// 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" {
|
if node.Word != "show" {
|
||||||
t.Errorf("'show bogus tail' should stop at show, got %q", node.Word)
|
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
|
// Verify the weight command is fully walkable (fixes bug: setWeightValue
|
||||||
// and setFrontendPoolName were non-slot nodes that couldn't capture tokens).
|
// and setFrontendPoolName were non-slot nodes that couldn't capture tokens).
|
||||||
root := buildTree()
|
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 {
|
if node.Run == nil {
|
||||||
t.Fatal("Walk did not reach a Run node for full weight command")
|
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])
|
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"
|
"strconv"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"golang.org/x/sys/unix"
|
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/encoding/protojson"
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
|
|
||||||
|
"git.ipng.ch/ipng/golang-cli/keypress"
|
||||||
"git.ipng.ch/ipng/vpp-maglev/internal/grpcapi"
|
"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"}
|
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.
|
// 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.
|
// If none of log/backend/frontend are mentioned, all three default to true.
|
||||||
func runWatchEvents(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
|
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
|
var wantLog, wantBackend, wantFrontend bool
|
||||||
logLevel := ""
|
logLevel := ""
|
||||||
anyExplicit := false
|
anyExplicit := false
|
||||||
@@ -84,7 +85,7 @@ func runWatchEvents(ctx context.Context, client grpcapi.MaglevClient, args []str
|
|||||||
watchCtx, cancel := context.WithCancel(ctx)
|
watchCtx, cancel := context.WithCancel(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
go watchStopOnKeypress(watchCtx, cancel)
|
go keypress.WaitForKey(watchCtx, cancel)
|
||||||
|
|
||||||
sigCh := make(chan os.Signal, 1)
|
sigCh := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
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
|
// Stopping the watch on a keypress now uses keypress.WaitForKey from the
|
||||||
// calls cancel when any byte arrives. Cbreak mode disables canonical (line)
|
// golang-cli library (per-platform cbreak handling, with a non-tty fallback).
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-1
@@ -150,7 +150,7 @@ func run() error {
|
|||||||
if vppClient != nil {
|
if vppClient != nil {
|
||||||
vppSrc = vppClient
|
vppSrc = vppClient
|
||||||
}
|
}
|
||||||
metrics.Register(reg, chkr, vppSrc)
|
metrics.Register(reg, chkr, vppSrc, cfg.SourceTag, buildinfo.Version(), buildinfo.Commit())
|
||||||
reg.MustRegister(grpcMetrics)
|
reg.MustRegister(grpcMetrics)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
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
|
.SH NAME
|
||||||
maglevc \- Maglev health\-checker CLI client
|
maglevc \- Maglev health\-checker CLI client
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
.B maglevc
|
.B maglevc
|
||||||
[\fB\-server\fR \fIaddr\fR]
|
[\fB\-server\fR \fIaddr\fR]
|
||||||
[\fB\-color\fR[=\fIbool\fR]]
|
[\fB\-color\fR[=\fIbool\fR]]
|
||||||
|
[\fB\-json\fR]
|
||||||
[\fIcommand\fR [\fIargs\fR...]]
|
[\fIcommand\fR [\fIargs\fR...]]
|
||||||
.SH DESCRIPTION
|
.SH DESCRIPTION
|
||||||
.B maglevc
|
.B maglevc
|
||||||
@@ -38,7 +39,7 @@ gRPC server.
|
|||||||
.RI "(default: " localhost:9090 "; env: " MAGLEV_SERVER )
|
.RI "(default: " localhost:9090 "; env: " MAGLEV_SERVER )
|
||||||
.TP
|
.TP
|
||||||
.BR \-color [=\fIbool\fR]
|
.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
|
default is mode\-aware: enabled (true) in the interactive shell, and
|
||||||
disabled (false) in one\-shot mode so that output piped into scripts
|
disabled (false) in one\-shot mode so that output piped into scripts
|
||||||
or files stays free of escape codes. Pass
|
or files stays free of escape codes. Pass
|
||||||
@@ -47,6 +48,18 @@ or
|
|||||||
.B \-color=false
|
.B \-color=false
|
||||||
explicitly to override the default for either mode.
|
explicitly to override the default for either mode.
|
||||||
.TP
|
.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
|
.B \-version
|
||||||
Print version, commit hash, and build date, then exit.
|
Print version, commit hash, and build date, then exit.
|
||||||
.SH EXAMPLES
|
.SH EXAMPLES
|
||||||
@@ -84,6 +97,20 @@ Query VPP version and connection status, forcing color on:
|
|||||||
$ maglevc \-color=true show vpp info
|
$ maglevc \-color=true show vpp info
|
||||||
.EE
|
.EE
|
||||||
.RE
|
.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"
|
.SH "FULL DOCUMENTATION"
|
||||||
This manpage documents only the invocation of
|
This manpage documents only the invocation of
|
||||||
.BR maglevc .
|
.BR maglevc .
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ module git.ipng.ch/ipng/vpp-maglev
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
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/prometheus/client_golang v1.23.2
|
||||||
github.com/vishvananda/netns v0.0.5
|
github.com/vishvananda/netns v0.0.5
|
||||||
go.fd.io/govpp v0.12.0
|
go.fd.io/govpp v0.12.0
|
||||||
golang.org/x/net v0.52.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/grpc v1.80.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
@@ -18,16 +20,14 @@ require (
|
|||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // 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/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/ansi v0.10.1 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.1 // 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/ftrvxmtrx/fd v0.0.0-20150925145434-c6d800382fff // 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/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 // indirect
|
||||||
github.com/kr/text v0.2.0 // indirect
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.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/sirupsen/logrus v1.9.3 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.2 // 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
|
golang.org/x/text v0.35.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // 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 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
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.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 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
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 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
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-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-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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
|
|||||||
@@ -378,10 +378,9 @@ func (c *Checker) GetBackendInfo(name string) (metrics.BackendInfo, bool) {
|
|||||||
return metrics.BackendInfo{}, false
|
return metrics.BackendInfo{}, false
|
||||||
}
|
}
|
||||||
return metrics.BackendInfo{
|
return metrics.BackendInfo{
|
||||||
Health: w.backend,
|
Health: w.backend,
|
||||||
Enabled: w.entry.Enabled,
|
Enabled: w.entry.Enabled,
|
||||||
HCName: w.entry.HealthCheck,
|
HCName: w.entry.HealthCheck,
|
||||||
SourceTag: w.entry.SourceTag,
|
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
|
|
||||||
// Config is the top-level parsed and validated configuration.
|
// Config is the top-level parsed and validated configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
SourceTag string // this node's nginx source tag; defaults to the short hostname
|
||||||
HealthChecker HealthCheckerConfig
|
HealthChecker HealthCheckerConfig
|
||||||
VPP VPPConfig
|
VPP VPPConfig
|
||||||
HealthChecks map[string]HealthCheck
|
HealthChecks map[string]HealthCheck
|
||||||
@@ -123,7 +124,6 @@ type TCPParams struct {
|
|||||||
type Backend struct {
|
type Backend struct {
|
||||||
Address net.IP
|
Address net.IP
|
||||||
HealthCheck string // name reference into Config.HealthChecks; "" = no probing, assume healthy
|
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
|
Enabled bool // default true; false = exclude from serving entirely
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +164,7 @@ type rawConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type rawMaglev struct {
|
type rawMaglev struct {
|
||||||
|
SourceTag string `yaml:"source-tag"`
|
||||||
HealthChecker rawHealthCheckerCfg `yaml:"healthchecker"`
|
HealthChecker rawHealthCheckerCfg `yaml:"healthchecker"`
|
||||||
VPP rawVPPCfg `yaml:"vpp"`
|
VPP rawVPPCfg `yaml:"vpp"`
|
||||||
HealthChecks map[string]rawHealthCheck `yaml:"healthchecks"`
|
HealthChecks map[string]rawHealthCheck `yaml:"healthchecks"`
|
||||||
@@ -219,8 +220,7 @@ type rawParams struct {
|
|||||||
type rawBackend struct {
|
type rawBackend struct {
|
||||||
Address string `yaml:"address"`
|
Address string `yaml:"address"`
|
||||||
HealthCheck string `yaml:"healthcheck"`
|
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 {
|
type rawPoolBackend struct {
|
||||||
@@ -301,6 +301,18 @@ func parse(data []byte) (*Config, error) {
|
|||||||
func convert(r *rawMaglev) (*Config, error) {
|
func convert(r *rawMaglev) (*Config, error) {
|
||||||
cfg := &Config{}
|
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 --------------------------------------------------------
|
// ---- healthchecker --------------------------------------------------------
|
||||||
cfg.HealthChecker.Netns = r.HealthChecker.Netns
|
cfg.HealthChecker.Netns = r.HealthChecker.Netns
|
||||||
cfg.HealthChecker.TransitionHistory = r.HealthChecker.TransitionHistory
|
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)
|
return Backend{}, fmt.Errorf("invalid address %q", r.Address)
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceTag := r.SourceTag
|
|
||||||
if sourceTag == "" {
|
|
||||||
sourceTag = name
|
|
||||||
}
|
|
||||||
b := Backend{
|
b := Backend{
|
||||||
Address: ip,
|
Address: ip,
|
||||||
HealthCheck: r.HealthCheck,
|
HealthCheck: r.HealthCheck,
|
||||||
SourceTag: sourceTag,
|
|
||||||
Enabled: boolDefault(r.Enabled, true),
|
Enabled: boolDefault(r.Enabled, true),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-24
@@ -21,10 +21,9 @@ import (
|
|||||||
|
|
||||||
// BackendInfo holds the health and config state needed by the collector.
|
// BackendInfo holds the health and config state needed by the collector.
|
||||||
type BackendInfo struct {
|
type BackendInfo struct {
|
||||||
Health *health.Backend
|
Health *health.Backend
|
||||||
Enabled bool
|
Enabled bool
|
||||||
HCName string // healthcheck name from config
|
HCName string // healthcheck name from config
|
||||||
SourceTag string // nginx source tag; equals backend name when unset in config
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// StateSource provides read-only access to the running checker state.
|
// 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
|
// on each scrape. This avoids stale label sets when backends are added or
|
||||||
// removed by a config reload.
|
// removed by a config reload.
|
||||||
type Collector struct {
|
type Collector struct {
|
||||||
src StateSource
|
src StateSource
|
||||||
vpp VPPSource // optional; nil when VPP integration is disabled
|
vpp VPPSource // optional; nil when VPP integration is disabled
|
||||||
|
sourceTag string
|
||||||
|
version string
|
||||||
|
commit string
|
||||||
|
|
||||||
backendInfo *prometheus.Desc
|
maglevInfo *prometheus.Desc
|
||||||
backendState *prometheus.Desc
|
backendState *prometheus.Desc
|
||||||
backendHealth *prometheus.Desc
|
backendHealth *prometheus.Desc
|
||||||
backendEnabled *prometheus.Desc
|
backendEnabled *prometheus.Desc
|
||||||
@@ -153,15 +155,18 @@ type Collector struct {
|
|||||||
|
|
||||||
// NewCollector creates a Collector backed by the given StateSource. vpp may
|
// NewCollector creates a Collector backed by the given StateSource. vpp may
|
||||||
// be nil when VPP integration is disabled; in that case vpp_* metrics are
|
// be nil when VPP integration is disabled; in that case vpp_* metrics are
|
||||||
// simply not emitted.
|
// simply not emitted. version and commit are surfaced via maglev_info labels.
|
||||||
func NewCollector(src StateSource, vpp VPPSource) *Collector {
|
func NewCollector(src StateSource, vpp VPPSource, sourceTag, version, commit string) *Collector {
|
||||||
return &Collector{
|
return &Collector{
|
||||||
src: src,
|
src: src,
|
||||||
vpp: vpp,
|
vpp: vpp,
|
||||||
backendInfo: prometheus.NewDesc(
|
sourceTag: sourceTag,
|
||||||
"maglev_backend_info",
|
version: version,
|
||||||
"Static backend metadata. Always 1; metadata is conveyed via labels.",
|
commit: commit,
|
||||||
[]string{"backend", "address", "healthcheck", "source_tag"}, nil,
|
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(
|
backendState: prometheus.NewDesc(
|
||||||
"maglev_backend_state",
|
"maglev_backend_state",
|
||||||
@@ -223,7 +228,7 @@ func NewCollector(src StateSource, vpp VPPSource) *Collector {
|
|||||||
|
|
||||||
// Describe implements prometheus.Collector.
|
// Describe implements prometheus.Collector.
|
||||||
func (c *Collector) Describe(ch chan<- *prometheus.Desc) {
|
func (c *Collector) Describe(ch chan<- *prometheus.Desc) {
|
||||||
ch <- c.backendInfo
|
ch <- c.maglevInfo
|
||||||
ch <- c.backendState
|
ch <- c.backendState
|
||||||
ch <- c.backendHealth
|
ch <- c.backendHealth
|
||||||
ch <- c.backendEnabled
|
ch <- c.backendEnabled
|
||||||
@@ -239,6 +244,9 @@ func (c *Collector) Describe(ch chan<- *prometheus.Desc) {
|
|||||||
|
|
||||||
// Collect implements prometheus.Collector.
|
// Collect implements prometheus.Collector.
|
||||||
func (c *Collector) Collect(ch chan<- prometheus.Metric) {
|
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{
|
states := []health.State{
|
||||||
health.StateUnknown,
|
health.StateUnknown,
|
||||||
health.StateUp,
|
health.StateUp,
|
||||||
@@ -255,11 +263,6 @@ func (c *Collector) Collect(ch chan<- prometheus.Metric) {
|
|||||||
}
|
}
|
||||||
addr := info.Health.Address.String()
|
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.
|
// One time-series per possible state; the current state is 1, rest 0.
|
||||||
for _, s := range states {
|
for _, s := range states {
|
||||||
val := 0.0
|
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
|
// Register registers all metrics with the given registry. vpp may be nil
|
||||||
// to disable VPP-related metrics.
|
// to disable VPP-related metrics. version / commit are surfaced via
|
||||||
func Register(reg prometheus.Registerer, src StateSource, vpp VPPSource) *Collector {
|
// maglev_info labels.
|
||||||
coll := NewCollector(src, vpp)
|
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(coll)
|
||||||
reg.MustRegister(ProbeTotal)
|
reg.MustRegister(ProbeTotal)
|
||||||
reg.MustRegister(ProbeDuration)
|
reg.MustRegister(ProbeDuration)
|
||||||
|
|||||||
Reference in New Issue
Block a user