Refactor CLI: birdc-style help, collapsed nouns, ReloadConfig, bug fixes

maglevc
- Rewrite '?' handler (birdc-style): show full command paths from current
  position to every leaf, right-aligned help column, dynamic slot values
  displayed as an indented block when cursor is at a slot position.
- Collapse show frontends/frontend, backends/backend, healthchecks/healthcheck
  into single plural-noun nodes with an optional <name> slot. Allows
  'sh ba' (list all) and 'sh ba nginx0' (show one) without ambiguity.
- Add 'config reload' command.
- Fix tabwriter ANSI alignment: continuation lines in transition output
  now carry the same label() byte overhead as the header line.
- Fix broken Walk for 'set frontend' command: setFrontendPoolName and
  setWeightValue were fixed-word nodes that couldn't capture user input;
  mark them as slot nodes with dynNone.
- Add tree_test.go covering expandPaths, cycle detection, prefix matching,
  and the full weight-command walk.

gRPC / proto
- Add ReloadConfig RPC: checks config then applies it to the running
  checker, returning ok/parse_error/semantic_error/reload_error.
- Add logging to CheckConfig (config-check-start/config-check-done at
  INFO level).

maglevd
- SIGHUP handler now calls maglevServer.TriggerReload(), sharing the
  same code path as the gRPC ReloadConfig RPC.

docs
- Collapse show command documentation to use [<name>] optional syntax.
- Remove developer-facing 'Command tree and parser' section.
- Document 'config reload'.
This commit is contained in:
2026-04-11 18:20:43 +02:00
parent 58391f5463
commit 3bd30b69f4
11 changed files with 657 additions and 222 deletions

View File

@@ -27,58 +27,55 @@ func buildTree() *Node {
exit := &Node{Word: "exit", Help: "exit the shell", Run: runQuit}
// show version
showVersion := &Node{Word: "version", Help: "show build version", Run: runShowVersion}
showVersion := &Node{Word: "version", Help: "Show build version", Run: runShowVersion}
// show frontends
showFrontends := &Node{Word: "frontends", Help: "list all frontends", Run: runShowFrontends}
// show frontend <name>
// show frontends [<name>] — without name: list all, with name: show details
showFrontendName := &Node{
Word: "<name>",
Help: "frontend name",
Help: "Show details for a single frontend",
Dynamic: dynFrontends,
Run: runShowFrontend,
}
showFrontend := &Node{
Word: "frontend",
Help: "show a single frontend",
showFrontends := &Node{
Word: "frontends",
Help: "List all frontends",
Run: runShowFrontends,
Children: []*Node{showFrontendName},
}
// show backends
showBackends := &Node{Word: "backends", Help: "list all backends", Run: runShowBackends}
// show backend <name>
// show backends [<name>] — without name: list all, with name: show details
showBackendName := &Node{
Word: "<name>",
Help: "backend name",
Help: "Show details for a single backend",
Dynamic: dynBackends,
Run: runShowBackend,
}
showBackend := &Node{
Word: "backend",
Help: "show a single backend",
showBackends := &Node{
Word: "backends",
Help: "List all backends",
Run: runShowBackends,
Children: []*Node{showBackendName},
}
// show healthchecks
showHealthChecks := &Node{Word: "healthchecks", Help: "list all health checks", Run: runShowHealthChecks}
// show healthcheck <name>
// show healthchecks [<name>] — without name: list all, with name: show details
showHealthCheckName := &Node{
Word: "<name>",
Help: "health check name",
Help: "Show details for a single health check",
Dynamic: dynHealthChecks,
Run: runShowHealthCheck,
}
showHealthCheck := &Node{
Word: "healthcheck",
Help: "show a single health check",
showHealthChecks := &Node{
Word: "healthchecks",
Help: "List all health checks",
Run: runShowHealthChecks,
Children: []*Node{showHealthCheckName},
}
show.Children = []*Node{
showVersion,
showFrontends, showFrontend,
showBackends, showBackend,
showHealthChecks, showHealthCheck,
showFrontends,
showBackends,
showHealthChecks,
}
// set backend <name> pause|resume|disabled|enabled
@@ -99,9 +96,10 @@ func buildTree() *Node {
}
// set frontend <name> pool <pool> backend <name> weight <0-100>
setWeightValue := &Node{
Word: "<weight>",
Help: "weight 0-100",
Run: runSetFrontendPoolBackendWeight,
Word: "<weight>",
Help: "Set weight of a backend in a pool (0-100)",
Dynamic: dynNone, // accepts any integer; no tab-completion candidates
Run: runSetFrontendPoolBackendWeight,
}
setFrontendPoolBackendWeight := &Node{Word: "weight", Help: "set backend weight in pool", Children: []*Node{setWeightValue}}
setFrontendPoolBackendName := &Node{
@@ -114,6 +112,7 @@ func buildTree() *Node {
setFrontendPoolName := &Node{
Word: "<pool>",
Help: "pool name",
Dynamic: dynNone, // pool names aren't listed via gRPC; accepts any input
Children: []*Node{setFrontendPoolBackend},
}
setFrontendPool := &Node{Word: "pool", Help: "select a pool", Children: []*Node{setFrontendPoolName}}
@@ -139,7 +138,7 @@ func buildTree() *Node {
var watchEventsOptSlot *Node
watchEventsOptSlot = &Node{
Word: "<opt>",
Help: "watch option",
Help: "Stream events with options",
Dynamic: dynWatchEventOpts,
Run: runWatchEvents,
}
@@ -157,12 +156,13 @@ func buildTree() *Node {
Children: []*Node{watchEvents},
}
// config check
configCheck := &Node{Word: "check", Help: "check configuration file", Run: runConfigCheck}
// config check / reload
configCheck := &Node{Word: "check", Help: "Check configuration file", Run: runConfigCheck}
configReload := &Node{Word: "reload", Help: "Check and reload configuration", Run: runConfigReload}
configNode := &Node{
Word: "config",
Help: "configuration commands",
Children: []*Node{configCheck},
Children: []*Node{configCheck, configReload},
}
root.Children = []*Node{show, set, watch, configNode, quit, exit}
@@ -195,6 +195,10 @@ func dynHealthChecks(ctx context.Context, client grpcapi.MaglevClient) []string
return resp.Names
}
// dynNone marks a slot node that accepts any input but provides no
// tab-completion candidates (e.g. a pool name or numeric weight value).
func dynNone(_ context.Context, _ grpcapi.MaglevClient) []string { return nil }
// ---- run functions ---------------------------------------------------------
func runShowVersion(_ context.Context, _ grpcapi.MaglevClient, _ []string) error {
@@ -315,9 +319,14 @@ func runShowBackend(ctx context.Context, client grpcapi.MaglevClient, args []str
fmt.Fprintf(w, "%s\t%s\n", label("healthcheck"), info.Healthcheck)
for i, t := range info.Transitions {
ts := time.Unix(0, t.AtUnixNs)
lbl := ""
var lbl string
if i == 0 {
lbl = label("transitions")
} else {
// Pad to same visible width as "transitions" and wrap through
// label() so tabwriter sees the same byte count (ANSI overhead
// is identical on every row, keeping columns aligned).
lbl = label(" ")
}
fmt.Fprintf(w, "%s\t%s → %s\t%s\t%s\n",
lbl,
@@ -501,6 +510,26 @@ func runConfigCheck(ctx context.Context, client grpcapi.MaglevClient, _ []string
return fmt.Errorf("semantic error: %s", resp.SemanticError)
}
func runConfigReload(ctx context.Context, client grpcapi.MaglevClient, _ []string) error {
ctx, cancel := context.WithTimeout(ctx, callTimeout)
defer cancel()
resp, err := client.ReloadConfig(ctx, &grpcapi.ReloadConfigRequest{})
if err != nil {
return err
}
if resp.Ok {
fmt.Println("config reloaded")
return nil
}
if resp.ParseError != "" {
return fmt.Errorf("parse error: %s", resp.ParseError)
}
if resp.SemanticError != "" {
return fmt.Errorf("semantic error: %s", resp.SemanticError)
}
return fmt.Errorf("reload error: %s", resp.ReloadError)
}
// formatDuration formats a duration as Xd Xh Xm Xs without milliseconds.
func formatDuration(d time.Duration) string {
if d < 0 {