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:
@@ -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
174
cmd/maglevc/watch.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user