Add WatchEvents, enable/disable/weight RPCs, and config check

gRPC / proto
- Rename WatchBackendEvents → WatchEvents; return a stream of Event
  oneof (LogEvent, BackendEvent, FrontendEvent) with optional filter
  flags (log, log_level, backend, frontend)
- Add EnableBackend, DisableBackend, SetFrontendPoolBackendWeight RPCs
- Rename PauseResumeRequest → BackendRequest
- Add CheckConfig RPC returning ok/parse_error/semantic_error

maglevd
- Route slog through a LogBroadcaster (slog.Handler) so WatchEvents
  subscribers can receive structured log records independently of the
  daemon's own --log-level
- Add --reflection flag (default true) to toggle gRPC server reflection
- Add --check flag: validates config file and exits 0/1/2
- SIGHUP: use config.Check before applying reload; log parse vs semantic
  error separately; refuse reload on any error
- Rename default config path /etc/maglev → /etc/vpp-maglev

maglevc
- Add 'watch events [num <n>] [log [level <level>]] [backend] [frontend]'
  command; prints compact protojson, stops on any keypress or Ctrl-C;
  uses cbreak mode (not raw) so output post-processing is preserved
- Add 'set backend <name> enable|disable'
- Add 'set frontend <name> pool <pool> backend <name> weight <0-100>'
- Add 'config check' command

Debian packaging
- Rename service unit to vpp-maglevd.service
- Rename conffiles to /etc/default/vpp-maglev and /etc/vpp-maglev/
- Create maglevd system user/group in postinst; add to vpp group if present
- Add postrm; add adduser to Depends
This commit is contained in:
2026-04-11 16:42:11 +02:00
parent d612086a5f
commit 58391f5463
26 changed files with 1969 additions and 400 deletions

View File

@@ -6,6 +6,7 @@ import (
"context"
"fmt"
"os"
"strconv"
"strings"
"text/tabwriter"
"time"
@@ -83,8 +84,8 @@ func buildTree() *Node {
// set backend <name> pause|resume|disabled|enabled
setPause := &Node{Word: "pause", Help: "pause health checking", Run: runPauseBackend}
setResume := &Node{Word: "resume", Help: "resume health checking", Run: runResumeBackend}
setDisabled := &Node{Word: "disabled", Help: "disable backend (not implemented)", Run: runNotImplemented}
setEnabled := &Node{Word: "enabled", Help: "enable backend (not implemented)", Run: runNotImplemented}
setDisabled := &Node{Word: "disable", Help: "disable backend (stop probing, remove from rotation)", Run: runDisableBackend}
setEnabled := &Node{Word: "enable", Help: "enable backend (resume probing)", Run: runEnableBackend}
setBackendName := &Node{
Word: "<name>",
Help: "backend name",
@@ -96,9 +97,75 @@ func buildTree() *Node {
Help: "modify a backend",
Children: []*Node{setBackendName},
}
set.Children = []*Node{setBackend}
// set frontend <name> pool <pool> backend <name> weight <0-100>
setWeightValue := &Node{
Word: "<weight>",
Help: "weight 0-100",
Run: runSetFrontendPoolBackendWeight,
}
setFrontendPoolBackendWeight := &Node{Word: "weight", Help: "set backend weight in pool", Children: []*Node{setWeightValue}}
setFrontendPoolBackendName := &Node{
Word: "<backend>",
Help: "backend name",
Dynamic: dynBackends,
Children: []*Node{setFrontendPoolBackendWeight},
}
setFrontendPoolBackend := &Node{Word: "backend", Help: "select a backend", Children: []*Node{setFrontendPoolBackendName}}
setFrontendPoolName := &Node{
Word: "<pool>",
Help: "pool name",
Children: []*Node{setFrontendPoolBackend},
}
setFrontendPool := &Node{Word: "pool", Help: "select a pool", Children: []*Node{setFrontendPoolName}}
setFrontendName := &Node{
Word: "<name>",
Help: "frontend name",
Dynamic: dynFrontends,
Children: []*Node{setFrontendPool},
}
setFrontend := &Node{
Word: "frontend",
Help: "modify a frontend",
Children: []*Node{setFrontendName},
}
root.Children = []*Node{show, set, quit, exit}
set.Children = []*Node{setBackend, setFrontend}
// watch events [num <n>] [log [level <level>]] [backend] [frontend]
//
// All tokens after 'events' are captured as args via a self-referencing slot
// node. This lets runWatchEvents parse the optional flags manually while still
// providing tab-completion through the dynamic enumerator.
var watchEventsOptSlot *Node
watchEventsOptSlot = &Node{
Word: "<opt>",
Help: "watch option",
Dynamic: dynWatchEventOpts,
Run: runWatchEvents,
}
watchEventsOptSlot.Children = []*Node{watchEventsOptSlot}
watchEvents := &Node{
Word: "events",
Help: "stream events (press any key or Ctrl-C to stop)",
Run: runWatchEvents,
Children: []*Node{watchEventsOptSlot},
}
watch := &Node{
Word: "watch",
Help: "watch live event streams",
Children: []*Node{watchEvents},
}
// config check
configCheck := &Node{Word: "check", Help: "check configuration file", Run: runConfigCheck}
configNode := &Node{
Word: "config",
Help: "configuration commands",
Children: []*Node{configCheck},
}
root.Children = []*Node{show, set, watch, configNode, quit, exit}
return root
}
@@ -332,7 +399,7 @@ func runPauseBackend(ctx context.Context, client grpcapi.MaglevClient, args []st
}
ctx, cancel := context.WithTimeout(ctx, callTimeout)
defer cancel()
info, err := client.PauseBackend(ctx, &grpcapi.PauseResumeRequest{Name: args[0]})
info, err := client.PauseBackend(ctx, &grpcapi.BackendRequest{Name: args[0]})
if err != nil {
return err
}
@@ -346,7 +413,7 @@ func runResumeBackend(ctx context.Context, client grpcapi.MaglevClient, args []s
}
ctx, cancel := context.WithTimeout(ctx, callTimeout)
defer cancel()
info, err := client.ResumeBackend(ctx, &grpcapi.PauseResumeRequest{Name: args[0]})
info, err := client.ResumeBackend(ctx, &grpcapi.BackendRequest{Name: args[0]})
if err != nil {
return err
}
@@ -354,11 +421,86 @@ func runResumeBackend(ctx context.Context, client grpcapi.MaglevClient, args []s
return nil
}
func runNotImplemented(_ context.Context, _ grpcapi.MaglevClient, _ []string) error {
fmt.Println("not implemented yet")
func runSetFrontendPoolBackendWeight(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
if len(args) != 4 {
return fmt.Errorf("usage: set frontend <name> pool <pool> backend <name> weight <0-100>")
}
frontendName, poolName, backendName, weightStr := args[0], args[1], args[2], args[3]
weight, err := strconv.Atoi(weightStr)
if err != nil || weight < 0 || weight > 100 {
return fmt.Errorf("weight: expected integer 0-100, got %q", weightStr)
}
ctx, cancel := context.WithTimeout(ctx, callTimeout)
defer cancel()
info, err := client.SetFrontendPoolBackendWeight(ctx, &grpcapi.SetWeightRequest{
Frontend: frontendName,
Pool: poolName,
Backend: backendName,
Weight: int32(weight),
})
if err != nil {
return err
}
// Print the updated pool so the user can confirm the new weight.
for _, pool := range info.Pools {
if pool.Name != poolName {
continue
}
for _, pb := range pool.Backends {
if pb.Name == backendName {
fmt.Printf("%s pool %s backend %s: weight set to %d\n", info.Name, pool.Name, pb.Name, pb.Weight)
return nil
}
}
}
return nil
}
func runEnableBackend(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
if len(args) == 0 {
return fmt.Errorf("usage: set backend <name> enable")
}
ctx, cancel := context.WithTimeout(ctx, callTimeout)
defer cancel()
info, err := client.EnableBackend(ctx, &grpcapi.BackendRequest{Name: args[0]})
if err != nil {
return err
}
fmt.Printf("%s: enabled, state is '%s'\n", info.Name, info.State)
return nil
}
func runDisableBackend(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
if len(args) == 0 {
return fmt.Errorf("usage: set backend <name> disable")
}
ctx, cancel := context.WithTimeout(ctx, callTimeout)
defer cancel()
info, err := client.DisableBackend(ctx, &grpcapi.BackendRequest{Name: args[0]})
if err != nil {
return err
}
fmt.Printf("%s: disabled, state is '%s'\n", info.Name, info.State)
return nil
}
func runConfigCheck(ctx context.Context, client grpcapi.MaglevClient, _ []string) error {
ctx, cancel := context.WithTimeout(ctx, callTimeout)
defer cancel()
resp, err := client.CheckConfig(ctx, &grpcapi.CheckConfigRequest{})
if err != nil {
return err
}
if resp.Ok {
fmt.Println("config ok")
return nil
}
if resp.ParseError != "" {
return fmt.Errorf("parse error: %s", resp.ParseError)
}
return fmt.Errorf("semantic error: %s", resp.SemanticError)
}
// formatDuration formats a duration as Xd Xh Xm Xs without milliseconds.
func formatDuration(d time.Duration) string {
if d < 0 {

174
cmd/maglevc/watch.go Normal file
View File

@@ -0,0 +1,174 @@
// Copyright (c) 2026, Pim van Pelt <pim@ipng.ch>
package main
import (
"context"
"fmt"
"os"
"os/signal"
"strconv"
"syscall"
"golang.org/x/sys/unix"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/encoding/protojson"
"git.ipng.ch/ipng/vpp-maglev/internal/grpcapi"
)
func dynWatchEventOpts(_ context.Context, _ grpcapi.MaglevClient) []string {
return []string{"num", "log", "backend", "frontend"}
}
// runWatchEvents implements 'watch events [num <n>] [log [level <level>]] [backend] [frontend]'.
// All tokens after 'events' are captured as args by the circular slot node in the tree.
// If none of log/backend/frontend are mentioned, all three default to true.
func runWatchEvents(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
var maxEvents int // 0 = unlimited
var wantLog, wantBackend, wantFrontend bool
logLevel := ""
anyExplicit := false
for i := 0; i < len(args); {
switch args[i] {
case "num":
if i+1 >= len(args) {
return fmt.Errorf("num requires a count argument")
}
n, err := strconv.Atoi(args[i+1])
if err != nil || n < 1 {
return fmt.Errorf("num: invalid count %q", args[i+1])
}
maxEvents = n
i += 2
case "log":
wantLog = true
anyExplicit = true
if i+1 < len(args) && args[i+1] == "level" {
if i+2 >= len(args) {
return fmt.Errorf("log level requires a level argument")
}
logLevel = args[i+2]
i += 3
} else {
i++
}
case "backend":
wantBackend = true
anyExplicit = true
i++
case "frontend":
wantFrontend = true
anyExplicit = true
i++
default:
return fmt.Errorf("unknown watch option %q; expected: num, log, backend, frontend", args[i])
}
}
if !anyExplicit {
wantLog, wantBackend, wantFrontend = true, true, true
}
boolp := func(b bool) *bool { v := b; return &v }
req := &grpcapi.WatchRequest{
Log: boolp(wantLog),
LogLevel: logLevel,
Backend: boolp(wantBackend),
Frontend: boolp(wantFrontend),
}
// Cancel the stream on keypress or signal.
watchCtx, cancel := context.WithCancel(ctx)
defer cancel()
go watchStopOnKeypress(watchCtx, cancel)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(sigCh)
go func() {
select {
case <-sigCh:
cancel()
case <-watchCtx.Done():
}
}()
stream, err := client.WatchEvents(watchCtx, req)
if err != nil {
return err
}
marshaler := protojson.MarshalOptions{}
count := 0
for {
ev, err := stream.Recv()
if err != nil {
if watchCtx.Err() != nil {
return nil // stopped by keypress or signal
}
if st, ok := status.FromError(err); ok && st.Code() == codes.Canceled {
return nil
}
return err
}
data, err := marshaler.Marshal(ev)
if err != nil {
return fmt.Errorf("marshal event: %w", err)
}
fmt.Printf("%s\n", data)
count++
if maxEvents > 0 && count >= maxEvents {
return nil
}
}
}
// watchStopOnKeypress puts stdin into cbreak mode (when it is a terminal) and
// calls cancel when any byte arrives. Cbreak mode disables canonical (line)
// input so a single keypress is sufficient, while preserving output
// post-processing (OPOST/ONLCR) so that fmt.Printf("\n") still produces the
// expected carriage-return+newline on screen. Falls back gracefully when stdin
// is not a tty. The goroutine exits when ctx is cancelled.
func watchStopOnKeypress(ctx context.Context, cancel context.CancelFunc) {
fd := int(os.Stdin.Fd())
if old, err := stdinCbreak(fd); err == nil {
defer unix.IoctlSetTermios(fd, unix.TCSETSF, old) //nolint:errcheck
}
readDone := make(chan struct{})
go func() {
defer close(readDone)
buf := make([]byte, 1)
os.Stdin.Read(buf) //nolint:errcheck
}()
select {
case <-readDone:
cancel()
case <-ctx.Done():
}
}
// stdinCbreak sets the terminal referred to by fd into cbreak mode: canonical
// input and echo are disabled (so single keystrokes are immediately available)
// but output post-processing is left untouched (so \n still maps to \r\n).
// Returns the previous termios so the caller can restore it, or an error if fd
// is not a terminal.
func stdinCbreak(fd int) (*unix.Termios, error) {
old, err := unix.IoctlGetTermios(fd, unix.TCGETS)
if err != nil {
return nil, err // not a terminal
}
t := *old
t.Lflag &^= unix.ICANON | unix.ECHO | unix.ECHOE | unix.ECHOK | unix.ECHONL
t.Cc[unix.VMIN] = 1
t.Cc[unix.VTIME] = 0
if err := unix.IoctlSetTermios(fd, unix.TCSETS, &t); err != nil {
return nil, err
}
return old, nil
}

View File

@@ -13,6 +13,7 @@ import (
"syscall"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
buildinfo "git.ipng.ch/ipng/vpp-maglev/cmd"
"git.ipng.ch/ipng/vpp-maglev/internal/checker"
@@ -30,7 +31,9 @@ func main() {
func run() error {
// ---- flags / env --------------------------------------------------------
printVersion := flag.Bool("version", false, "print version and exit")
configPath := stringFlag("config", "/etc/maglev/frontend.yaml", "MAGLEV_CONFIG", "path to frontend.yaml")
checkOnly := flag.Bool("check", false, "check config file and exit (0=ok, 1=parse error, 2=semantic error)")
enableReflection := flag.Bool("reflection", true, "enable gRPC server reflection (for grpcurl)")
configPath := stringFlag("config", "/etc/vpp-maglev/maglev.yaml", "MAGLEV_CONFIG", "path to maglev.yaml")
grpcAddr := stringFlag("grpc-addr", ":9090", "MAGLEV_GRPC_ADDR", "gRPC listen address")
logLevel := stringFlag("log-level", "info", "MAGLEV_LOG_LEVEL", "log level (debug|info|warn|error)")
flag.Parse()
@@ -41,12 +44,28 @@ func run() error {
return nil
}
if *checkOnly {
_, result := config.Check(*configPath)
if result.OK() {
fmt.Printf("config ok: %s\n", *configPath)
return nil
}
if result.ParseError != "" {
fmt.Fprintf(os.Stderr, "parse error: %s\n", result.ParseError)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "semantic error: %s\n", result.SemanticError)
os.Exit(2)
}
// ---- logging ------------------------------------------------------------
var level slog.Level
if err := level.UnmarshalText([]byte(*logLevel)); err != nil {
return fmt.Errorf("invalid log level %q: %w", *logLevel, err)
}
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})))
jsonHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})
logBroadcaster := grpcapi.NewLogBroadcaster(jsonHandler)
slog.SetDefault(slog.New(logBroadcaster))
slog.Info("starting", "version", buildinfo.Version(), "commit", buildinfo.Commit(), "date", buildinfo.Date())
// ---- config -------------------------------------------------------------
@@ -74,8 +93,11 @@ func run() error {
return fmt.Errorf("listen %s: %w", *grpcAddr, err)
}
srv := grpc.NewServer()
grpcapi.RegisterMaglevServer(srv, grpcapi.NewServer(ctx, chkr))
slog.Info("grpc-listening", "addr", *grpcAddr)
grpcapi.RegisterMaglevServer(srv, grpcapi.NewServer(ctx, chkr, logBroadcaster, *configPath))
if *enableReflection {
reflection.Register(srv)
}
slog.Info("grpc-listening", "addr", *grpcAddr, "reflection", *enableReflection)
go func() {
if err := srv.Serve(lis); err != nil {
@@ -91,9 +113,13 @@ func run() error {
switch sig {
case syscall.SIGHUP:
slog.Info("config-reload-start")
newCfg, err := config.Load(*configPath)
if err != nil {
slog.Error("config-reload-error", "err", err)
newCfg, result := config.Check(*configPath)
if !result.OK() {
if result.ParseError != "" {
slog.Error("config-check-failed", "type", "parse", "err", result.ParseError)
} else {
slog.Error("config-check-failed", "type", "semantic", "err", result.SemanticError)
}
continue
}
if err := chkr.Reload(ctx, newCfg); err != nil {