diff --git a/.gitignore b/.gitignore index ec6697d..431555c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -bin/ +build/ /*.yaml docs/implementation/ diff --git a/Makefile b/Makefile index f6a1fa2..6dadbc5 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,39 @@ -BINARIES := maglevd maglevc -MODULE := git.ipng.ch/ipng/vpp-maglev -PROTO_DIR := proto -PROTO_FILE := $(PROTO_DIR)/maglev.proto -GEN_FILES := internal/grpcapi/maglev.pb.go internal/grpcapi/maglev_grpc.pb.go +BINARIES := maglevd maglevc +MODULE := git.ipng.ch/ipng/vpp-maglev +PROTO_DIR := proto +PROTO_FILE := $(PROTO_DIR)/maglev.proto +GEN_FILES := internal/grpcapi/maglev.pb.go internal/grpcapi/maglev_grpc.pb.go -.PHONY: all build test proto lint clean +NATIVE_ARCH := $(shell go env GOARCH) +VERSION := 0.1.1 +COMMIT_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) +DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +LDFLAGS := -X '$(MODULE)/cmd.version=$(VERSION)' \ + -X '$(MODULE)/cmd.commit=$(COMMIT_HASH)' \ + -X '$(MODULE)/cmd.date=$(DATE)' + +.PHONY: all build build-amd64 build-arm64 test proto lint pkg-deb clean all: build build: $(GEN_FILES) - go build -o bin/maglevd ./cmd/maglevd/ - go build -o bin/maglevc ./cmd/maglevc/ + mkdir -p build/$(NATIVE_ARCH) + go build -ldflags "$(LDFLAGS)" -o build/$(NATIVE_ARCH)/maglevd ./cmd/maglevd/ + go build -ldflags "$(LDFLAGS)" -o build/$(NATIVE_ARCH)/maglevc ./cmd/maglevc/ + +build-amd64: $(GEN_FILES) + mkdir -p build/amd64 + GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o build/amd64/maglevd ./cmd/maglevd/ + GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o build/amd64/maglevc ./cmd/maglevc/ + +build-arm64: $(GEN_FILES) + mkdir -p build/arm64 + GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o build/arm64/maglevd ./cmd/maglevd/ + GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o build/arm64/maglevc ./cmd/maglevc/ + +pkg-deb: build-amd64 build-arm64 + debian/build-deb.sh amd64 $(VERSION) $(COMMIT_HASH) + debian/build-deb.sh arm64 $(VERSION) $(COMMIT_HASH) test: $(GEN_FILES) go test ./... @@ -27,5 +50,5 @@ lint: golangci-lint run ./... clean: - rm -f $(addprefix bin/,$(BINARIES)) + rm -rf build/ rm -f $(GEN_FILES) diff --git a/README.md b/README.md index b408ac4..f7c2328 100644 --- a/README.md +++ b/README.md @@ -5,29 +5,58 @@ Health checker and gRPC control plane for VPP Maglev load balancing. ## Build ```sh -make # builds bin/maglevd +make # builds build//maglevd and build//maglevc make test # runs all tests make proto # regenerates gRPC stubs from proto/maglev.proto +make lint # runs golangci-lint ``` -Requires Go 1.25+ and (for `make proto`) `protoc` with `protoc-gen-go` and `protoc-gen-go-grpc`. +Requires Go 1.25+ and (for `make proto`) `protoc` with `protoc-gen-go` and +`protoc-gen-go-grpc`. + +## Debian package + +```sh +make pkg-deb +``` + +Produces `vpp-maglev__amd64.deb` and `vpp-maglev__arm64.deb` +in the project root by cross-compiling with `GOOS=linux GOARCH=`. +Requires `dpkg-deb` (available on any Debian/Ubuntu host). + +The package installs: + +| Path | Content | +|---|---| +| `/usr/sbin/maglevd` | Health-checker daemon | +| `/usr/bin/maglevc` | Interactive CLI client | +| `/lib/systemd/system/maglevd.service` | systemd unit | +| `/etc/default/maglev` | Environment file for the unit (conffile) | +| `/etc/maglev/maglev.yaml` | Example configuration file (conffile) | +| `/usr/share/man/man8/maglevd.8.gz` | Man page | +| `/usr/share/man/man1/maglevc.1.gz` | Man page | + +After installing, the unit is enabled but not started automatically: + +```sh +# edit /etc/maglev/maglev.yaml, then: +systemctl start maglevd +``` ## Run ```sh maglevd --config /etc/maglev/maglev.yaml --grpc-addr :9090 +maglevd --version # print version and exit + +maglevc --server localhost:9090 # interactive shell +maglevc show backend nginx0-ams # one-shot +maglevc -color=false show backends # one-shot, no ANSI color +maglevc set backend nginx0-ams pause ``` -| Flag | Env | Default | -|---|---|---| -| `--config` | `MAGLEV_CONFIG` | `/etc/maglev/frontend.yaml` | -| `--grpc-addr` | `MAGLEV_GRPC_ADDR` | `:9090` | -| `--log-level` | `MAGLEV_LOG_LEVEL` | `info` | - -Send `SIGHUP` to reload the config without restarting. Backends whose health-check config is -unchanged continue probing uninterrupted. - -`maglevd` requires `CAP_NET_RAW` to open raw ICMP sockets. +Send `SIGHUP` to `maglevd` to reload config without restarting. +`maglevd` requires `CAP_NET_RAW` for ICMP health checks. ## Minimal config @@ -51,10 +80,16 @@ maglev: address: 192.0.2.1 protocol: tcp port: 80 - backends: [web0, web1] + pools: + - name: primary + backends: + web0: {} + web1: {} ``` +See [docs/user-guide.md](docs/user-guide.md) for flags, signals, and `maglevc` usage. See [docs/config-guide.md](docs/config-guide.md) for the full configuration reference. +See [docs/healthchecks.md](docs/healthchecks.md) for health state machine details. ## Docker diff --git a/cmd/maglevc/color.go b/cmd/maglevc/color.go new file mode 100644 index 0000000..0ec843a --- /dev/null +++ b/cmd/maglevc/color.go @@ -0,0 +1,22 @@ +// Copyright (c) 2026, Pim van Pelt + +package main + +const ( + ansiBlue = "\x1b[34m" + 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. +// Because every label receives the same fixed-length prefix/suffix, tabwriter +// alignment is preserved: the extra bytes are equal for all rows so relative +// widths remain correct. +func label(s string) string { + if !colorEnabled { + return s + } + return ansiBlue + s + ansiReset +} diff --git a/cmd/maglevc/commands.go b/cmd/maglevc/commands.go index 1d0d389..c727015 100644 --- a/cmd/maglevc/commands.go +++ b/cmd/maglevc/commands.go @@ -10,6 +10,7 @@ import ( "text/tabwriter" "time" + buildinfo "git.ipng.ch/ipng/vpp-maglev/cmd" "git.ipng.ch/ipng/vpp-maglev/internal/grpcapi" ) @@ -24,6 +25,9 @@ func buildTree() *Node { quit := &Node{Word: "quit", Help: "exit the shell", Run: runQuit} exit := &Node{Word: "exit", Help: "exit the shell", Run: runQuit} + // show version + showVersion := &Node{Word: "version", Help: "show build version", Run: runShowVersion} + // show frontends showFrontends := &Node{Word: "frontends", Help: "list all frontends", Run: runShowFrontends} // show frontend @@ -70,6 +74,7 @@ func buildTree() *Node { } show.Children = []*Node{ + showVersion, showFrontends, showFrontend, showBackends, showBackend, showHealthChecks, showHealthCheck, @@ -125,6 +130,12 @@ func dynHealthChecks(ctx context.Context, client grpcapi.MaglevClient) []string // ---- run functions --------------------------------------------------------- +func runShowVersion(_ context.Context, _ grpcapi.MaglevClient, _ []string) error { + fmt.Printf("maglevc %s (commit %s, built %s)\n", + buildinfo.Version(), buildinfo.Commit(), buildinfo.Date()) + return nil +} + func runQuit(_ context.Context, _ grpcapi.MaglevClient, _ []string) error { return errQuit } @@ -152,22 +163,53 @@ func runShowFrontend(ctx context.Context, client grpcapi.MaglevClient, args []st if err != nil { return err } + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintf(w, "name\t%s\n", info.Name) - fmt.Fprintf(w, "address\t%s\n", info.Address) - fmt.Fprintf(w, "protocol\t%s\n", info.Protocol) - fmt.Fprintf(w, "port\t%d\n", info.Port) - for i, b := range info.BackendNames { - if i == 0 { - fmt.Fprintf(w, "backends\t%s\n", b) - } else { - fmt.Fprintf(w, "\t%s\n", b) + fmt.Fprintf(w, "%s\t%s\n", label("name"), info.Name) + fmt.Fprintf(w, "%s\t%s\n", label("address"), info.Address) + fmt.Fprintf(w, "%s\t%s\n", label("protocol"), info.Protocol) + fmt.Fprintf(w, "%s\t%d\n", label("port"), info.Port) + if info.Description != "" { + fmt.Fprintf(w, "%s\t%s\n", label("description"), info.Description) + } + if len(info.Pools) > 0 { + fmt.Fprintf(w, "%s\n", label("pools")) + } + if err := w.Flush(); err != nil { + return err + } + + // Pool section uses direct Printf with fixed-width padding so that ANSI + // escape codes in labels don't confuse tabwriter's byte-based alignment. + // "backends" is always the widest pool label (8 chars); all pool labels + // are right-padded to that width, giving a 2+8+2 = 12-char visual indent. + const poolLblWidth = len("backends") + const poolIndent = " " + const poolSep = " " + contIndent := strings.Repeat(" ", len(poolIndent)+poolLblWidth+len(poolSep)) + + for _, pool := range info.Pools { + namePad := strings.Repeat(" ", poolLblWidth-len("name")) + fmt.Printf("%s%s%s%s%s\n", poolIndent, label("name"), namePad, poolSep, pool.Name) + for i, pb := range pool.Backends { + beInfo, beErr := client.GetBackend(ctx, &grpcapi.GetBackendRequest{Name: pb.Name}) + suffix := "" + if beErr == nil && !beInfo.Enabled { + suffix = " [disabled]" + } + weightStr := "" + if pb.Weight != 100 { + weightStr = fmt.Sprintf(" %s %d", label("weight"), pb.Weight) + } + if i == 0 { + bePad := strings.Repeat(" ", poolLblWidth-len("backends")) + fmt.Printf("%s%s%s%s%s%s%s\n", poolIndent, label("backends"), bePad, poolSep, pb.Name, weightStr, suffix) + } else { + fmt.Printf("%s%s%s%s\n", contIndent, pb.Name, weightStr, suffix) + } } } - if info.Description != "" { - fmt.Fprintf(w, "description\t%s\n", info.Description) - } - return w.Flush() + return nil } func runShowBackends(ctx context.Context, client grpcapi.MaglevClient, _ []string) error { @@ -194,25 +236,24 @@ func runShowBackend(ctx context.Context, client grpcapi.MaglevClient, args []str return err } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintf(w, "name\t%s\n", info.Name) - fmt.Fprintf(w, "address\t%s\n", info.Address) + fmt.Fprintf(w, "%s\t%s\n", label("name"), info.Name) + fmt.Fprintf(w, "%s\t%s\n", label("address"), info.Address) stateDur := "" if len(info.Transitions) > 0 { since := time.Since(time.Unix(0, info.Transitions[0].AtUnixNs)) stateDur = " for " + formatDuration(since) } - fmt.Fprintf(w, "state\t%s%s\n", info.State, stateDur) - fmt.Fprintf(w, "enabled\t%v\n", info.Enabled) - fmt.Fprintf(w, "weight\t%d\n", info.Weight) - fmt.Fprintf(w, "healthcheck\t%s\n", info.Healthcheck) + fmt.Fprintf(w, "%s\t%s%s\n", label("state"), info.State, stateDur) + fmt.Fprintf(w, "%s\t%v\n", label("enabled"), info.Enabled) + fmt.Fprintf(w, "%s\t%s\n", label("healthcheck"), info.Healthcheck) for i, t := range info.Transitions { ts := time.Unix(0, t.AtUnixNs) - label := "" + lbl := "" if i == 0 { - label = "transitions" + lbl = label("transitions") } fmt.Fprintf(w, "%s\t%s → %s\t%s\t%s\n", - label, + lbl, t.From, t.To, ts.Format("2006-01-02 15:04:05.000"), formatAgo(time.Since(ts)), @@ -245,41 +286,41 @@ func runShowHealthCheck(ctx context.Context, client grpcapi.MaglevClient, args [ return err } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintf(w, "name\t%s\n", info.Name) - fmt.Fprintf(w, "type\t%s\n", info.Type) + fmt.Fprintf(w, "%s\t%s\n", label("name"), info.Name) + fmt.Fprintf(w, "%s\t%s\n", label("type"), info.Type) if info.Port > 0 { - fmt.Fprintf(w, "port\t%d\n", info.Port) + fmt.Fprintf(w, "%s\t%d\n", label("port"), info.Port) } - fmt.Fprintf(w, "interval\t%s\n", time.Duration(info.IntervalNs)) + fmt.Fprintf(w, "%s\t%s\n", label("interval"), time.Duration(info.IntervalNs)) if info.FastIntervalNs > 0 { - fmt.Fprintf(w, "fast-interval\t%s\n", time.Duration(info.FastIntervalNs)) + fmt.Fprintf(w, "%s\t%s\n", label("fast-interval"), time.Duration(info.FastIntervalNs)) } if info.DownIntervalNs > 0 { - fmt.Fprintf(w, "down-interval\t%s\n", time.Duration(info.DownIntervalNs)) + fmt.Fprintf(w, "%s\t%s\n", label("down-interval"), time.Duration(info.DownIntervalNs)) } - fmt.Fprintf(w, "timeout\t%s\n", time.Duration(info.TimeoutNs)) - fmt.Fprintf(w, "rise\t%d\n", info.Rise) - fmt.Fprintf(w, "fall\t%d\n", info.Fall) + fmt.Fprintf(w, "%s\t%s\n", label("timeout"), time.Duration(info.TimeoutNs)) + fmt.Fprintf(w, "%s\t%d\n", label("rise"), info.Rise) + fmt.Fprintf(w, "%s\t%d\n", label("fall"), info.Fall) if info.ProbeIpv4Src != "" { - fmt.Fprintf(w, "probe-ipv4-src\t%s\n", info.ProbeIpv4Src) + fmt.Fprintf(w, "%s\t%s\n", label("probe-ipv4-src"), info.ProbeIpv4Src) } if info.ProbeIpv6Src != "" { - fmt.Fprintf(w, "probe-ipv6-src\t%s\n", info.ProbeIpv6Src) + fmt.Fprintf(w, "%s\t%s\n", label("probe-ipv6-src"), info.ProbeIpv6Src) } if h := info.Http; h != nil { - fmt.Fprintf(w, "http.path\t%s\n", h.Path) + fmt.Fprintf(w, "%s\t%s\n", label("http.path"), h.Path) if h.Host != "" { - fmt.Fprintf(w, "http.host\t%s\n", h.Host) + fmt.Fprintf(w, "%s\t%s\n", label("http.host"), h.Host) } - fmt.Fprintf(w, "http.response-code\t%d-%d\n", h.ResponseCodeMin, h.ResponseCodeMax) + fmt.Fprintf(w, "%s\t%d-%d\n", label("http.response-code"), h.ResponseCodeMin, h.ResponseCodeMax) if h.ResponseRegexp != "" { - fmt.Fprintf(w, "http.response-regexp\t%s\n", h.ResponseRegexp) + fmt.Fprintf(w, "%s\t%s\n", label("http.response-regexp"), h.ResponseRegexp) } } if t := info.Tcp; t != nil { - fmt.Fprintf(w, "tcp.ssl\t%v\n", t.Ssl) + fmt.Fprintf(w, "%s\t%v\n", label("tcp.ssl"), t.Ssl) if t.ServerName != "" { - fmt.Fprintf(w, "tcp.server-name\t%s\n", t.ServerName) + fmt.Fprintf(w, "%s\t%s\n", label("tcp.server-name"), t.ServerName) } } return w.Flush() diff --git a/cmd/maglevc/main.go b/cmd/maglevc/main.go index 6381dce..a11a798 100644 --- a/cmd/maglevc/main.go +++ b/cmd/maglevc/main.go @@ -12,6 +12,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" + buildinfo "git.ipng.ch/ipng/vpp-maglev/cmd" "git.ipng.ch/ipng/vpp-maglev/internal/grpcapi" ) @@ -24,7 +25,9 @@ func main() { func run() error { serverAddr := flag.String("server", "localhost:9090", "maglev server address") + color := flag.Bool("color", true, "colorize static labels in output") flag.Parse() + colorEnabled = *color conn, err := grpc.NewClient(*serverAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) @@ -38,7 +41,9 @@ func run() error { args := flag.Args() if len(args) == 0 { - // Interactive shell. + // Interactive shell: announce version on startup. + fmt.Printf("maglevc %s (commit %s, built %s)\n", + buildinfo.Version(), buildinfo.Commit(), buildinfo.Date()) return runShell(ctx, client) } diff --git a/cmd/maglevd/main.go b/cmd/maglevd/main.go index f9caa6e..6b6e362 100644 --- a/cmd/maglevd/main.go +++ b/cmd/maglevd/main.go @@ -14,6 +14,7 @@ import ( "google.golang.org/grpc" + buildinfo "git.ipng.ch/ipng/vpp-maglev/cmd" "git.ipng.ch/ipng/vpp-maglev/internal/checker" "git.ipng.ch/ipng/vpp-maglev/internal/config" "git.ipng.ch/ipng/vpp-maglev/internal/grpcapi" @@ -28,17 +29,25 @@ 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") 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() + if *printVersion { + fmt.Printf("maglevd %s (commit %s, built %s)\n", + buildinfo.Version(), buildinfo.Commit(), buildinfo.Date()) + return nil + } + // ---- 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}))) + slog.Info("starting", "version", buildinfo.Version(), "commit", buildinfo.Commit(), "date", buildinfo.Date()) // ---- config ------------------------------------------------------------- cfg, err := config.Load(*configPath) diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..4141cbc --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,14 @@ +// Copyright (c) 2026, Pim van Pelt + +package cmd + +// Set at build time via -ldflags. +var ( + version = "dev" + commit = "unknown" + date = "unknown" +) + +func Version() string { return version } +func Commit() string { return commit } +func Date() string { return date } diff --git a/debian/build-deb.sh b/debian/build-deb.sh new file mode 100755 index 0000000..090e11b --- /dev/null +++ b/debian/build-deb.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# Build a vpp-maglev Debian package for one architecture. +# Usage: build-deb.sh +set -euo pipefail + +ARCH="${1:?usage: build-deb.sh }" +VERSION="${2:?usage: build-deb.sh }" +COMMIT="${3:?usage: build-deb.sh }" + +FULL_VERSION="${VERSION}~${COMMIT}" +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PKG="vpp-maglev_${FULL_VERSION}_${ARCH}" +STAGING="$(mktemp -d)" +trap 'rm -rf "$STAGING"' EXIT + +echo "Building ${PKG}.deb" + +# Directories +install -d "$STAGING/usr/sbin" +install -d "$STAGING/usr/bin" +install -d "$STAGING/usr/share/man/man1" +install -d "$STAGING/usr/share/man/man8" +install -d "$STAGING/lib/systemd/system" +install -d "$STAGING/etc/default" +install -d "$STAGING/etc/maglev" +install -d "$STAGING/DEBIAN" + +# Binaries +install -m 755 "$REPO_ROOT/build/${ARCH}/maglevd" "$STAGING/usr/sbin/maglevd" +install -m 755 "$REPO_ROOT/build/${ARCH}/maglevc" "$STAGING/usr/bin/maglevc" + +# Man pages +gzip -9 -c "$REPO_ROOT/docs/maglevd.8" > "$STAGING/usr/share/man/man8/maglevd.8.gz" +gzip -9 -c "$REPO_ROOT/docs/maglevc.1" > "$STAGING/usr/share/man/man1/maglevc.1.gz" + +# Systemd unit +install -m 644 "$REPO_ROOT/debian/maglevd.service" "$STAGING/lib/systemd/system/maglevd.service" + +# /etc/default/maglev (conffile — dpkg won't overwrite on upgrade) +install -m 644 "$REPO_ROOT/debian/default.maglev" "$STAGING/etc/default/maglev" + +# /etc/maglev/maglev.yaml (conffile) +install -m 644 "$REPO_ROOT/debian/maglev.yaml" "$STAGING/etc/maglev/maglev.yaml" + +# DEBIAN/control (version field uses full_version including commit) +sed "s/@VERSION@/${FULL_VERSION}/;s/@ARCH@/${ARCH}/" \ + "$REPO_ROOT/debian/control.in" > "$STAGING/DEBIAN/control" + +# DEBIAN/conffiles, postinst, prerm +install -m 644 "$REPO_ROOT/debian/conffiles" "$STAGING/DEBIAN/conffiles" +install -m 755 "$REPO_ROOT/debian/postinst" "$STAGING/DEBIAN/postinst" +install -m 755 "$REPO_ROOT/debian/prerm" "$STAGING/DEBIAN/prerm" + +# Emit package into build/ +mkdir -p "$REPO_ROOT/build" +OUT="$REPO_ROOT/build/${PKG}.deb" +dpkg-deb --build --root-owner-group "$STAGING" "$OUT" +echo "Built: $OUT" diff --git a/debian/conffiles b/debian/conffiles new file mode 100644 index 0000000..8d217c4 --- /dev/null +++ b/debian/conffiles @@ -0,0 +1,2 @@ +/etc/default/maglev +/etc/maglev/maglev.yaml diff --git a/debian/control.in b/debian/control.in new file mode 100644 index 0000000..f70ab89 --- /dev/null +++ b/debian/control.in @@ -0,0 +1,14 @@ +Package: vpp-maglev +Version: @VERSION@ +Architecture: @ARCH@ +Maintainer: Pim van Pelt +Section: net +Priority: optional +Depends: systemd +Description: Maglev health-checker daemon and CLI client + maglevd monitors backends (HTTP, TCP, ICMP) with a rise/fall counter + model and exposes their aggregated state over a gRPC API. Configuration + is loaded from a YAML file and supports live reload via SIGHUP. + . + maglevc is an interactive CLI client for maglevd with tab completion, + inline help, and one-shot mode for scripting. diff --git a/debian/default.maglev b/debian/default.maglev new file mode 100644 index 0000000..95a7afd --- /dev/null +++ b/debian/default.maglev @@ -0,0 +1,12 @@ +# Default settings for maglevd. +# This file is sourced by /lib/systemd/system/maglevd.service. +# After editing, run: systemctl restart maglevd + +# Path to the YAML configuration file. +MAGLEV_CONFIG=/etc/maglev/maglev.yaml + +# gRPC listen address (default: :9090) +#MAGLEV_GRPC_ADDR=:9090 + +# Log level: debug, info, warn, error (default: info) +#MAGLEV_LOG_LEVEL=info diff --git a/debian/maglev.yaml b/debian/maglev.yaml new file mode 100644 index 0000000..e9e15ac --- /dev/null +++ b/debian/maglev.yaml @@ -0,0 +1,56 @@ +maglev: + healthchecker: + transition-history: 5 + # netns: dataplane # run probes inside a named network namespace + + healthchecks: + http-check: + type: http + port: 80 + params: + path: / + host: www.example.com + response-code: "200-301" + interval: 5s + fast-interval: 1s + timeout: 3s + rise: 2 + fall: 6 + + tcp-ssl-check: + type: tcp + port: 443 + params: + ssl: true + server-name: www.example.com + interval: 10s + fast-interval: 1s + timeout: 3s + rise: 2 + fall: 6 + + backends: + web-1: + address: 192.0.2.10 + healthcheck: http-check + web-2: + address: 192.0.2.11 + healthcheck: http-check + web-3: + address: 192.0.2.12 + healthcheck: http-check + + frontends: + http-vip: + description: "HTTP VIP" + address: 192.0.2.1 + protocol: tcp + port: 80 + pools: + - name: primary + backends: + web-1: { weight: 10 } + web-2: {} + - name: fallback + backends: + web-3: {} diff --git a/debian/maglevd.service b/debian/maglevd.service new file mode 100644 index 0000000..c481e2b --- /dev/null +++ b/debian/maglevd.service @@ -0,0 +1,15 @@ +[Unit] +Description=Maglev health-checker daemon +Documentation=man:maglevd(8) +After=network-online.target +Wants=network-online.target + +[Service] +EnvironmentFile=/etc/default/maglev +ExecStart=/usr/sbin/maglevd --config ${MAGLEV_CONFIG} +Restart=on-failure +RestartSec=5s +Type=simple + +[Install] +WantedBy=multi-user.target diff --git a/debian/postinst b/debian/postinst new file mode 100644 index 0000000..8ec3b92 --- /dev/null +++ b/debian/postinst @@ -0,0 +1,8 @@ +#!/bin/sh +set -e +case "$1" in + configure) + systemctl daemon-reload || true + systemctl enable maglevd.service || true + ;; +esac diff --git a/debian/prerm b/debian/prerm new file mode 100644 index 0000000..dfce330 --- /dev/null +++ b/debian/prerm @@ -0,0 +1,8 @@ +#!/bin/sh +set -e +case "$1" in + remove|purge) + systemctl stop maglevd.service || true + systemctl disable maglevd.service || true + ;; +esac diff --git a/docs/config-guide.md b/docs/config-guide.md index 74754f1..771d182 100644 --- a/docs/config-guide.md +++ b/docs/config-guide.md @@ -77,12 +77,13 @@ Common fields (all types): * ***probe-ipv6-src***: An optional IPv6 source address used when probing IPv6 backends. Must be an IPv6 address. When omitted, the OS chooses the source address. * ***interval***: Required. A positive Go duration string (e.g. `2s`, `500ms`) controlling - how often a probe is sent when the backend is fully healthy or in the initial unknown state. + how often a probe is sent when the backend is fully healthy (counter at maximum). * ***fast-interval***: Optional. A positive duration used instead of `interval` while the - backend's health counter is degraded (between down and up). When omitted, `interval` is used. + backend's health counter is degraded (between down and up) or in `unknown` state. When + omitted, `interval` is used. * ***down-interval***: Optional. A positive duration used instead of `interval` while the - backend is fully down. When omitted, `interval` is used. Setting this to a longer value - reduces probe traffic to backends that are known to be offline. + backend is fully down (counter at zero). When omitted, `interval` is used. Setting this to + a longer value reduces probe traffic to backends that are known to be offline. * ***timeout***: Required. A positive duration after which an in-flight probe is abandoned and counted as a failure. * ***rise***: The number of consecutive successes required to transition from down to up. @@ -193,9 +194,6 @@ multiple frontends. * ***enabled***: A boolean controlling whether this backend participates in any frontend. When `false`, the backend is excluded entirely and no probe goroutine is started. Defaults to `true`. -* ***weight***: An integer between 0 and 100 (inclusive) expressing the relative weight of - this backend in a frontend's pool. `0` keeps the backend in the pool but assigns it no - traffic. Defaults to `100`. Examples: ```yaml @@ -206,7 +204,6 @@ backends: nginx0-lon: address: 198.51.100.11 healthcheck: nginx-http - weight: 50 nginx0-draining: address: 198.51.100.12 healthcheck: nginx-http @@ -220,8 +217,8 @@ backends: ## frontends -A named map of virtual IPs (VIPs). Each frontend ties together a listener address with a set -of backends. The gRPC API exposes frontends by name. +A named map of virtual IPs (VIPs). Each frontend ties together a listener address with an +ordered list of backend pools. The gRPC API exposes frontends by name. * ***description***: An optional free-text string for documentation purposes. * ***address***: Required. The IPv4 or IPv6 address of the VIP. @@ -232,38 +229,50 @@ of backends. The gRPC API exposes frontends by name. `protocol` to be set. When omitted, the frontend matches all ports. Note that the frontend port is independent of the healthcheck port: a frontend on port 443 may use a healthcheck that probes port 80. -* ***backends***: Required. A non-empty list of backend names. All backends in a frontend - must have addresses of the same address family (all IPv4 or all IPv6). Every name must - refer to an existing entry in the `backends` section. +* ***pools***: Required. A non-empty ordered list of pool objects. Pools express priority: + the first pool is preferred; subsequent pools act as fallbacks. All backends across all + pools in a frontend must have addresses of the same address family (all IPv4 or all IPv6). + +Each pool has: + +* ***name***: Required. A non-empty string identifying the pool (e.g. `primary`, `fallback`). +* ***backends***: A map of backend names to per-pool backend options. Every name must refer + to an existing entry in the `backends` section. + +Per-pool backend options: + +* ***weight***: An integer between 0 and 100 (inclusive) expressing the relative weight of + this backend within the pool. `0` keeps the backend in the pool but assigns it no traffic. + Defaults to `100`. Weight is per-pool, not global — the same backend can appear with + different weights in different frontends. Examples: ```yaml frontends: nginx-v4-http: - description: "IPv4 HTTP VIP" + description: "IPv4 HTTP VIP with fallback" address: 198.51.100.1 protocol: tcp port: 80 - backends: [nginx0-ams, nginx0-lon] - - nginx-v4-https: - description: "IPv4 HTTPS VIP — reuses the same backends as HTTP" - address: 198.51.100.1 - protocol: tcp - port: 443 - backends: [nginx0-ams, nginx0-lon] + pools: + - name: primary + backends: + nginx0-ams: { weight: 10 } + nginx0-lon: {} + - name: fallback + backends: + nginx0-fra: {} maildrop-imaps: description: "IMAPS VIP" address: 2001:db8::1 protocol: tcp port: 993 - backends: [maildrop0-ams, maildrop0-lon] - - catchall: - description: "Match all traffic to this VIP regardless of protocol or port" - address: 198.51.100.2 - backends: [static-backend] + pools: + - name: primary + backends: + maildrop0-ams: {} + maildrop0-lon: {} ``` --- @@ -322,7 +331,6 @@ maglev: nginx0-fra: address: 198.51.100.12 healthcheck: nginx - weight: 50 maildrop0-ams: address: 2001:db8:1::10 healthcheck: dovecot @@ -332,23 +340,46 @@ maglev: frontends: nginx-http: - description: "HTTP VIP" + description: "HTTP VIP with fallback" address: 198.51.100.1 protocol: tcp port: 80 - backends: [nginx0-ams, nginx0-lon, nginx0-fra] + pools: + - name: primary + backends: + nginx0-ams: { weight: 10 } + nginx0-lon: {} + - name: fallback + backends: + nginx0-fra: {} nginx-https: description: "HTTPS VIP — same backends, different port" address: 198.51.100.1 protocol: tcp port: 443 - backends: [nginx0-ams, nginx0-lon, nginx0-fra] + pools: + - name: primary + backends: + nginx0-ams: { weight: 10 } + nginx0-lon: {} + - name: fallback + backends: + nginx0-fra: {} maildrop-imaps: description: "IMAPS VIP" address: 2001:db8::1 protocol: tcp port: 993 - backends: [maildrop0-ams, maildrop0-lon] + pools: + - name: primary + backends: + maildrop0-ams: {} + maildrop0-lon: {} ``` + +--- + +For a detailed description of the health state machine, probe intervals, and all +transition events, see [healthchecks.md](healthchecks.md). diff --git a/docs/healthchecks.md b/docs/healthchecks.md new file mode 100644 index 0000000..e05f3b0 --- /dev/null +++ b/docs/healthchecks.md @@ -0,0 +1,178 @@ +# Health Checking + +`maglevd` probes each backend independently of how many frontends reference it. +Every backend runs exactly one probe goroutine. State changes are broadcast as +gRPC events to all connected `WatchBackendEvents` subscribers. + +--- + +## States + +| State | Meaning | +|---|---| +| `unknown` | Initial state; also entered after a resume or backend restart. | +| `up` | Backend is healthy and eligible to receive traffic. | +| `down` | Backend has failed enough consecutive probes to be considered offline. | +| `paused` | Health checking suspended by an operator. Probes fire but results are discarded. | +| `removed` | Backend was removed from configuration. No further probes are accepted. | + +--- + +## Rise / fall counter + +The state machine is driven by HAProxy's single-integer health counter. + +``` +counter ∈ [0, rise + fall − 1] (called Max below) + +backend is UP when counter ≥ rise +backend is DOWN when counter < rise +``` + +On each probe: +- **pass** — counter increments, ceiling at Max. +- **fail** — counter decrements, floor at 0. + +This gives **hysteresis**: a backend that is barely up (counter = rise) needs +`fall` consecutive failures before it transitions to down. A backend that is +fully down (counter = 0) needs `rise` consecutive passes to come back up. A +backend that oscillates between passing and failing stays in the degraded range +without bouncing between up and down. + +### Expedited unknown resolution + +When a backend enters `unknown` state (new, restarted, or resumed) its counter +is pre-loaded to `rise − 1`. This means a single probe result is enough to +resolve the state: + +- **1 pass** → `up` +- **1 fail** → `down` (also via the special unknown shortcut below) + +In addition, any failure while state is `unknown` transitions immediately to +`down`, regardless of the counter value. + +### Example: rise=2, fall=3 (Max=4) + +``` +counter: 0 1 2 3 4 +state: DOWN DOWN UP UP UP + ^ + rise boundary +``` + +A backend starting from unknown has counter=1 (rise−1). One pass → counter=2 +→ up. One fail while unknown → down immediately. + +A backend that just became up sits at counter=2. It needs 3 failures to go down +(2→1→0, crossing the rise boundary at 2→1). + +A backend that has been fully healthy for a while sits at counter=4. It needs 3 +failures to go down (4→3→2→1, crossing the rise boundary at 2→1). + +--- + +## Probe intervals + +The interval used between probes depends on the backend's counter state: + +| Condition | Interval used | +|---|---| +| State is `unknown` | `fast-interval` (falls back to `interval`) | +| Counter = Max (fully healthy) | `interval` | +| Counter = 0 (fully down) | `down-interval` (falls back to `interval`) | +| Counter between 0 and Max (degraded) | `fast-interval` (falls back to `interval`) | + +Using `fast-interval` in degraded and unknown states means a flapping or +recovering backend is re-evaluated quickly without waiting a full `interval`. +Using `down-interval` for fully down backends reduces probe traffic to servers +that are known to be offline. + +--- + +## Transition events + +Every state change is logged as `backend-transition` and emitted as a gRPC +`BackendEvent` to all active `WatchBackendEvents` streams. + +### Backend added (config load or reload) + +``` +unknown → unknown (code: start) +``` + +The counter is pre-loaded to `rise − 1`. The first probe fires immediately at +`fast-interval` (or `interval` if not configured). One pass produces `unknown → +up`; one fail produces `unknown → down`. + +If multiple backends start together they are staggered across the first +`interval` to avoid probe bursts. + +### Probe pass + +- Counter increments. +- If counter reaches `rise` from below: `down → up` (or `unknown → up`). +- If already up: no transition. Next probe at `fast-interval` if degraded, + `interval` if fully healthy. + +### Probe fail + +- Counter decrements. +- If counter drops below `rise` from above: `up → down`. +- If state is `unknown`: transition immediately to `down` regardless of counter. +- Next probe at `down-interval` if fully down, `fast-interval` if degraded. + +### Pause + +``` + → paused (operator action) +``` + +The counter is reset to 0. Probes continue to fire on their normal schedule but +all results are discarded. The backend stays `paused` until explicitly resumed. + +### Resume + +``` +paused → unknown (operator action) +``` + +The counter is reset to `rise − 1`. The probe goroutine is woken immediately +(no wait for the next scheduled probe). One subsequent pass produces `unknown → +up`; one fail produces `unknown → down`. + +### Backend removed (config reload) + +``` + → removed (code: removed) +``` + +The probe goroutine stops. No further state changes occur. The removed event is +emitted using the frontend map from before the reload so that consumers can +correlate it to the correct frontend. + +### Backend healthcheck config changed (config reload) + +The old probe goroutine is stopped (` → removed`) and a new one started +(`unknown → unknown`, code: `start`). The new goroutine resolves state on the +first probe as described under *Backend added* above. + +### Backend metadata changed without healthcheck change (config reload) + +Weight, enabled flag, and similar fields are updated in place. The probe +goroutine is not restarted and no transition event is emitted. + +--- + +## Log lines + +All state changes produce a structured log line at `INFO` level: + +```json +{"level":"INFO","msg":"backend-transition","backend":"nginx0-ams","from":"up","to":"paused"} +{"level":"INFO","msg":"backend-transition","backend":"nginx0-ams","from":"paused","to":"unknown"} +{"level":"INFO","msg":"backend-transition","backend":"nginx0-ams","from":"unknown","to":"up","code":"L7OK","detail":""} +``` + +Probe-driven transitions also carry `code` and `detail` fields from the probe +result (e.g. `L4CON`, `L7STS`, `connection refused`). Operator-driven +transitions (pause, resume) carry empty code and detail. diff --git a/docs/maglevc.1 b/docs/maglevc.1 new file mode 100644 index 0000000..4dab0e9 --- /dev/null +++ b/docs/maglevc.1 @@ -0,0 +1,112 @@ +.TH MAGLEVC 1 "April 2026" "vpp\-maglev" "User Commands" +.SH NAME +maglevc \- Maglev health\-checker CLI client +.SH SYNOPSIS +.B maglevc +[\fB\-server\fR \fIaddr\fR] +[\fB\-color\fR[=\fIbool\fR]] +[\fIcommand\fR [\fIargs\fR...]] +.SH DESCRIPTION +.B maglevc +is an interactive CLI client for +.BR maglevd (8). +Without arguments it opens a readline shell with tab completion and +inline help. +A command may also be passed directly on the command line for one\-shot use, +which is useful for scripting (use +.B \-color=false +to suppress ANSI codes). +.PP +When the shell starts it prints the build version and connects to the +.B maglevd +gRPC server specified by +.BR \-server . +.SH OPTIONS +.TP +.BI \-server " addr" +Address of the +.B maglevd +gRPC server. +(default: +.IR localhost:9090 ) +.TP +.BR \-color [=\fIbool\fR] +Colorize static field labels in output using ANSI dark blue. +(default: true) +Pass +.B \-color=false +to disable, e.g.\& when piping output. +.SH COMMANDS +Commands are entered at the +.B maglevc> +prompt or passed as arguments on the command line. +All static tokens support tab completion; dynamic names (frontend, backend, +health\-check names) are completed by querying the server. +Type +.B ? +at any point to list completions without advancing the input. +.SS Show commands +.TP +.B show version +Print build version, commit hash, and build date. +.TP +.B show frontends +List all configured frontends. +.TP +.BI "show frontend " name +Show address, protocol, port, description, and pools (with weights and +disabled\-backend notation) for the named frontend. +.TP +.B show backends +List all active backends. +.TP +.BI "show backend " name +Show address, current health state (with duration), enabled flag, +health\-check name, and recent state transitions with timestamps. +.TP +.B show healthchecks +List all configured health checks. +.TP +.BI "show healthcheck " name +Show the full configuration of the named health check. +.SS Set commands +.TP +.BI "set backend " "name " pause +Pause health checking for a backend, freezing its state. +.TP +.BI "set backend " "name " resume +Resume health checking for a backend; state resets to +.BR unknown . +.SS Shell commands +.TP +.BR quit ", " exit +Exit the interactive shell. +.SH COMPLETION +In interactive mode, press +.B Tab +to complete the current token. +If more than one completion is possible, all candidates are listed. +Type +.B ? +anywhere on the line to list candidates at that position without consuming +the character or advancing the cursor. +.SH EXAMPLES +One\-shot query (no color, suitable for scripts): +.PP +.RS +.EX +maglevc \-color=false show backends +.EE +.RE +.PP +Interactive session: +.PP +.RS +.EX +maglevc \-server 10.0.0.1:9090 +.EE +.RE +.SH SEE ALSO +.BR maglevd (8) +.SH AUTHOR +Pim van Pelt diff --git a/docs/maglevd.8 b/docs/maglevd.8 new file mode 100644 index 0000000..c57109f --- /dev/null +++ b/docs/maglevd.8 @@ -0,0 +1,85 @@ +.TH MAGLEVD 8 "April 2026" "vpp\-maglev" "System Administration" +.SH NAME +maglevd \- Maglev health\-checker daemon +.SH SYNOPSIS +.B maglevd +[\fB\-config\fR \fIfile\fR] +[\fB\-grpc\-addr\fR \fIaddr\fR] +[\fB\-log\-level\fR \fIlevel\fR] +[\fB\-version\fR] +.SH DESCRIPTION +.B maglevd +is a health\-checker daemon that monitors backends (HTTP, TCP, ICMP) and +exposes their aggregated state via a gRPC API. +Configuration is loaded from a YAML file. +A running daemon reloads its configuration when it receives +.BR SIGHUP . +.PP +Backends are tracked with a rise/fall counter model. +Each backend cycles through the states +.BR unknown , +.BR up , +.BR down , +and +.B paused +(operator\-set). +Health\-check intervals adapt automatically: a faster interval is used when +a backend is not fully healthy, and a slower interval when it has been +continuously down. +.SH OPTIONS +Each flag may also be supplied via an environment variable (shown in +parentheses); the flag takes precedence. +.TP +.BI \-config " file" +Path to the YAML configuration file. +.RI "(default: " /etc/maglev/maglev.conf "; env: " MAGLEV_CONFIG ) +.TP +.BI \-grpc\-addr " addr" +TCP address on which the gRPC server listens. +.RI "(default: " :9090 "; env: " MAGLEV_GRPC_ADDR ) +.TP +.BI \-log\-level " level" +Structured\-log verbosity: +.BR debug , +.BR info , +.BR warn , +or +.BR error . +.RI "(default: " info "; env: " MAGLEV_LOG_LEVEL ) +.TP +.B \-version +Print version, commit hash, and build date, then exit. +.SH SIGNALS +.TP +.B SIGHUP +Reload the configuration file without restarting. +New backends are added, removed backends are stopped, and unchanged +backend workers are left running. +.TP +.BR SIGTERM ", " SIGINT +Gracefully shut down: drain active gRPC streams, then exit. +.SH FILES +.TP +.I /etc/maglev/maglev.conf +Default configuration file (YAML). +.TP +.I /etc/default/maglev +Environment file sourced by the systemd unit before starting +.BR maglevd . +.SH CONFIGURATION +The configuration file uses YAML and has four top\-level sections under the +.B maglev +key: +.BR healthchecker , +.BR healthchecks , +.BR backends , +and +.BR frontends . +.PP +See the example at +.I /etc/maglev/maglev.conf +and the full reference in the project documentation. +.SH SEE ALSO +.BR maglevc (1) +.SH AUTHOR +Pim van Pelt diff --git a/docs/user-guide.md b/docs/user-guide.md new file mode 100644 index 0000000..db7e3dc --- /dev/null +++ b/docs/user-guide.md @@ -0,0 +1,133 @@ +# User Guide + +## maglevd + +`maglevd` is the health-checker daemon. It probes backends according to the +configuration file, maintains their health state, and exposes a gRPC API for +inspection and control. + +### Flags + +| Flag | Environment variable | Default | Description | +|---|---|---|---| +| `--config` | `MAGLEV_CONFIG` | `/etc/maglev/maglev.yaml` | Path to the YAML configuration file. | +| `--grpc-addr` | `MAGLEV_GRPC_ADDR` | `:9090` | TCP address on which the gRPC server listens. | +| `--log-level` | `MAGLEV_LOG_LEVEL` | `info` | Log verbosity: `debug`, `info`, `warn`, or `error`. | +| `--version` | — | — | Print version, commit hash, and build date, then exit. | + +Flags take precedence over environment variables. Both are optional; defaults +are used for anything not set. + +### Signals + +| Signal | Effect | +|---|---| +| `SIGHUP` | Reload the configuration file. New backends are started, removed backends are stopped, backends whose health-check config is unchanged continue probing without interruption. | +| `SIGTERM` / `SIGINT` | Graceful shutdown. Active gRPC streams are closed, the server drains, then the process exits. | + +### Capabilities + +`maglevd` requires `CAP_NET_RAW` when any health check uses `type: icmp`. +All other check types (`tcp`, `http`) use normal TCP sockets and require no +special capabilities. + +### Logging + +All log output is written to stdout as JSON using Go's `log/slog`. The first +line logged after the logger is configured is a `starting` record that includes +`version`, `commit`, and `date`. Every state change emits a `backend-transition` +line at `INFO` level. Set `--log-level debug` to see individual probe attempts +and their outcomes. + +--- + +## maglevc + +`maglevc` is the interactive control-plane client. It connects to a running +`maglevd` over gRPC and either executes a single command or drops into an +interactive shell. + +### Usage + +```sh +maglevc [--server host:port] [--color[=bool]] [command...] +``` + +| Flag | Default | Description | +|---|---|---| +| `--server` | `localhost:9090` | Address of the `maglevd` gRPC server. | +| `--color` | `true` | Colorize static field labels in output (dark blue ANSI). Pass `--color=false` to disable, e.g. when piping. | + +When `command` arguments are supplied the command is executed and `maglevc` +exits. When no arguments are given an interactive shell is started and the +build version is printed on entry. + +### Commands + +``` +show version Print build version, commit hash, and build date. + +show frontends List all frontend names. +show frontend Show address, protocol, port, description, and pools. + Each pool lists its backends with weights (if != 100) + and marks disabled backends with [disabled]. + +show backends List all backend names. +show backend Show address, current state (with duration in that state), + enabled flag, health check, and recent state transitions + with timestamps and how long ago each occurred. + +show healthchecks List all health-check names. +show healthcheck Show full health-check configuration. + +set backend pause Suspend health checking for a backend, freezing its state. +set backend resume Resume health checking; backend re-enters unknown state + and is probed immediately. + +quit / exit Leave the interactive shell. +``` + +### Interactive shell + +The shell prompt is `maglev> `. Two completion mechanisms are available: + +**Tab completion** — pressing `` at any point completes the current token. +Fixed keywords (commands and subcommands) are completed from the command tree. +Backend, frontend, and health-check names are fetched live from the server with +a 1-second timeout. If the partial token is unambiguous the word is completed +in place; if multiple candidates exist they are listed and the prompt is +restored. + +**Inline help (`?`)** — typing `?` at any point prints the available +completions for the current position, with a short description next to each +keyword. The `?` character is not added to the input line. + +Commands and keywords support **prefix matching**: typing `sh b` is equivalent +to `show backend` provided the prefix is unambiguous. Exact matches always take +priority over prefix matches, so `show backend` and `show backends` are +unambiguous even though one is a prefix of the other. + +### Command tree and parser + +Commands form a tree of `Node` values. Each node has a fixed `Word` (a keyword) +or is a *slot node* (marked by a `Dynamic` function that enumerates valid +values at completion time). The parser (`Walk`) descends the tree token by +token: + +1. Try to match the current token against the fixed-keyword children of the + current node (exact match first, then unique prefix match). +2. If no fixed child matches, try a slot child — any token is accepted and + stored as an argument. +3. Stop when tokens are exhausted or no match is found. + +The leaf node reached by `Walk` must have a `Run` function; otherwise the +available sub-commands at that position are printed as help. Arguments +collected from slot nodes are passed to `Run` as a slice. + +Example walk for `set backend nginx0-ams pause`: + +``` +root → set → backend → (nginx0-ams collected as arg) → pause +``` + +`pause.Run` is called with `args = ["nginx0-ams"]`. diff --git a/internal/checker/checker.go b/internal/checker/checker.go index 888f64a..7819344 100644 --- a/internal/checker/checker.go +++ b/internal/checker/checker.go @@ -208,9 +208,16 @@ func (c *Checker) ListFrontendBackends(frontendName string) []*health.Backend { return nil } var out []*health.Backend - for _, name := range fe.Backends { - if w, ok := c.workers[name]; ok { - out = append(out, w.backend) + seen := map[string]struct{}{} + for _, pool := range fe.Pools { + for name := range pool.Backends { + if _, already := seen[name]; already { + continue + } + seen[name] = struct{}{} + if w, ok := c.workers[name]; ok { + out = append(out, w.backend) + } } } return out @@ -398,15 +405,22 @@ func (c *Checker) runProbe(ctx context.Context, name string, pos, total int) { } } -// emitForBackend emits one Event per frontend that references backendName, -// using the provided frontends map. Must be called with c.mu held. +// emitForBackend emits one Event per frontend that references backendName +// (in any pool), using the provided frontends map. Must be called with c.mu held. func (c *Checker) emitForBackend(backendName string, addr net.IP, t health.Transition, frontends map[string]config.Frontend) { for feName, fe := range frontends { - for _, name := range fe.Backends { - if name == backendName { - c.emit(Event{FrontendName: feName, BackendName: backendName, Backend: addr, Transition: t}) + emitted := false + for _, pool := range fe.Pools { + if emitted { break } + for name := range pool.Backends { + if name == backendName { + c.emit(Event{FrontendName: feName, BackendName: backendName, Backend: addr, Transition: t}) + emitted = true + break + } + } } } } @@ -491,13 +505,15 @@ func tcpParamsEqual(a, b *config.TCPParams) bool { } // activeBackendNames returns a sorted, deduplicated list of backend names that -// are referenced by at least one frontend and have Enabled: true. +// are referenced by at least one frontend pool and have Enabled: true. func activeBackendNames(cfg *config.Config) []string { seen := map[string]struct{}{} for _, fe := range cfg.Frontends { - for _, name := range fe.Backends { - if b, ok := cfg.Backends[name]; ok && b.Enabled { - seen[name] = struct{}{} + for _, pool := range fe.Pools { + for name := range pool.Backends { + if b, ok := cfg.Backends[name]; ok && b.Enabled { + seen[name] = struct{}{} + } } } } diff --git a/internal/checker/checker_test.go b/internal/checker/checker_test.go index 92c3ae2..28de8d7 100644 --- a/internal/checker/checker_test.go +++ b/internal/checker/checker_test.go @@ -29,7 +29,6 @@ func makeTestConfig(interval time.Duration, fall, rise int) *config.Config { Address: net.ParseIP("10.0.0.2"), HealthCheck: "icmp", Enabled: true, - Weight: 100, }, }, Frontends: map[string]config.Frontend{ @@ -37,7 +36,11 @@ func makeTestConfig(interval time.Duration, fall, rise int) *config.Config { Address: net.ParseIP("192.0.2.1"), Protocol: "tcp", Port: 80, - Backends: []string{"be0"}, + Pools: []config.Pool{ + {Name: "primary", Backends: map[string]config.PoolBackend{ + "be0": {Weight: 100}, + }}, + }, }, }, } @@ -116,13 +119,16 @@ func TestReloadAddsBackend(t *testing.T) { Address: net.ParseIP("10.0.0.3"), HealthCheck: "icmp", Enabled: true, - Weight: 100, } newCfg.Frontends["web2"] = config.Frontend{ Address: net.ParseIP("192.0.2.2"), Protocol: "tcp", Port: 443, - Backends: []string{"be1"}, + Pools: []config.Pool{ + {Name: "primary", Backends: map[string]config.PoolBackend{ + "be1": {Weight: 100}, + }}, + }, } ctx, cancel := context.WithCancel(context.Background()) @@ -186,7 +192,11 @@ func TestSharedBackendProbedOnce(t *testing.T) { Address: net.ParseIP("192.0.2.3"), Protocol: "tcp", Port: 443, - Backends: []string{"be0"}, + Pools: []config.Pool{ + {Name: "primary", Backends: map[string]config.PoolBackend{ + "be0": {Weight: 100}, + }}, + }, } c := New(cfg) diff --git a/internal/config/config.go b/internal/config/config.go index cf0d8fd..f6f4319 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -67,16 +67,26 @@ type Backend struct { Address net.IP HealthCheck string // name reference into Config.HealthChecks; "" = no probing, assume healthy Enabled bool // default true; false = exclude from serving entirely - Weight int // 0-100, default 100 +} + +// PoolBackend is a backend reference within a pool, with pool-local weight. +type PoolBackend struct { + Weight int // 0-100, default 100 +} + +// Pool is an ordered tier of backends within a frontend. +type Pool struct { + Name string + Backends map[string]PoolBackend // keyed by backend name } // Frontend is a single virtual IP entry. type Frontend struct { Description string Address net.IP - Protocol string // "tcp", "udp", or "" (all traffic) - Port uint16 // 0 means omitted (all ports) - Backends []string // backend names, each must exist in Config.Backends + Protocol string // "tcp", "udp", or "" (all traffic) + Port uint16 // 0 means omitted (all ports) + Pools []Pool // ordered tiers; first pool with any up backend is active } // ---- raw YAML types -------------------------------------------------------- @@ -127,15 +137,23 @@ type rawBackend struct { Address string `yaml:"address"` HealthCheck string `yaml:"healthcheck"` Enabled *bool `yaml:"enabled"` // nil → default true - Weight *int `yaml:"weight"` // nil → default 100 +} + +type rawPoolBackend struct { + Weight *int `yaml:"weight"` // nil → default 100 +} + +type rawPool struct { + Name string `yaml:"name"` + Backends map[string]rawPoolBackend `yaml:"backends"` } type rawFrontend struct { - Description string `yaml:"description"` - Address string `yaml:"address"` - Protocol string `yaml:"protocol"` - Port uint16 `yaml:"port"` - Backends []string `yaml:"backends"` + Description string `yaml:"description"` + Address string `yaml:"address"` + Protocol string `yaml:"protocol"` + Port uint16 `yaml:"port"` + Pools []rawPool `yaml:"pools"` } // ---- Load ------------------------------------------------------------------ @@ -319,11 +337,6 @@ func convertBackend(name string, r *rawBackend, hcs map[string]HealthCheck) (Bac Address: ip, HealthCheck: r.HealthCheck, Enabled: boolDefault(r.Enabled, true), - Weight: intDefault(r.Weight, 100), - } - - if b.Weight < 0 || b.Weight > 100 { - return Backend{}, fmt.Errorf("weight %d is out of range [0, 100]", b.Weight) } if b.HealthCheck != "" { @@ -340,7 +353,6 @@ func convertFrontend(name string, r *rawFrontend, backends map[string]Backend) ( Description: r.Description, Protocol: r.Protocol, Port: r.Port, - Backends: r.Backends, } ip := net.ParseIP(r.Address) @@ -361,21 +373,38 @@ func convertFrontend(name string, r *rawFrontend, backends map[string]Backend) ( return Frontend{}, fmt.Errorf("protocol %q requires port to be set (1-65535)", r.Protocol) } - if len(r.Backends) == 0 { - return Frontend{}, fmt.Errorf("backends must not be empty") + if len(r.Pools) == 0 { + return Frontend{}, fmt.Errorf("pools must not be empty") } var firstFamily int - for i, bName := range r.Backends { - b, ok := backends[bName] - if !ok { - return Frontend{}, fmt.Errorf("backends[%d] %q not defined", i, bName) + firstBackend := true + for pi, rp := range r.Pools { + if rp.Name == "" { + return Frontend{}, fmt.Errorf("pools[%d].name must not be empty", pi) } - fam := ipFamily(b.Address) - if i == 0 { - firstFamily = fam - } else if fam != firstFamily { - return Frontend{}, fmt.Errorf("backends[%d] %q has different address family than backends[0]", i, bName) + if len(rp.Backends) == 0 { + return Frontend{}, fmt.Errorf("pool %q backends must not be empty", rp.Name) } + pool := Pool{Name: rp.Name, Backends: make(map[string]PoolBackend, len(rp.Backends))} + for bName, rpb := range rp.Backends { + b, ok := backends[bName] + if !ok { + return Frontend{}, fmt.Errorf("pool %q backend %q not defined", rp.Name, bName) + } + fam := ipFamily(b.Address) + if firstBackend { + firstFamily = fam + firstBackend = false + } else if fam != firstFamily { + return Frontend{}, fmt.Errorf("pool %q backend %q has different address family than first backend", rp.Name, bName) + } + w := intDefault(rpb.Weight, 100) + if w < 0 || w > 100 { + return Frontend{}, fmt.Errorf("pool %q backend %q weight %d out of range [0, 100]", rp.Name, bName, w) + } + pool.Backends[bName] = PoolBackend{Weight: w} + } + fe.Pools = append(fe.Pools, pool) } return fe, nil diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9997686..35d70e3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -41,7 +41,6 @@ maglev: be-v6b: address: 2001:db8:2::2 healthcheck: icmp-check - weight: 50 enabled: true frontends: web4: @@ -49,13 +48,22 @@ maglev: address: 192.0.2.1 protocol: tcp port: 80 - backends: [be-v4] + pools: + - name: primary + backends: + be-v4: {} web6: description: "IPv6 VIP" address: 2001:db8::1 protocol: tcp port: 443 - backends: [be-v6a, be-v6b] + pools: + - name: primary + backends: + be-v6a: + weight: 100 + be-v6b: + weight: 50 ` func TestValidConfig(t *testing.T) { @@ -106,7 +114,7 @@ func TestValidConfig(t *testing.T) { t.Errorf("icmp-check probe-ipv6-src: got %s, want 2001:db8:1::1", icmp.ProbeIPv6Src) } - // Backend defaults and explicit fields. + // Backend fields. beV4 := cfg.Backends["be-v4"] if beV4.Address.String() != "192.0.2.10" { t.Errorf("be-v4 address: got %s", beV4.Address) @@ -117,23 +125,25 @@ func TestValidConfig(t *testing.T) { if !beV4.Enabled { t.Error("be-v4 enabled: want true (default)") } - if beV4.Weight != 100 { - t.Errorf("be-v4 weight: got %d, want 100 (default)", beV4.Weight) - } - beV6b := cfg.Backends["be-v6b"] - if beV6b.Weight != 50 { - t.Errorf("be-v6b weight: got %d, want 50", beV6b.Weight) - } - - // Frontend references. + // Pool structure. web4 := cfg.Frontends["web4"] - if len(web4.Backends) != 1 || web4.Backends[0] != "be-v4" { - t.Errorf("web4 backends: got %v", web4.Backends) + if len(web4.Pools) != 1 || web4.Pools[0].Name != "primary" { + t.Errorf("web4 pools: got %v", web4.Pools) } + if _, ok := web4.Pools[0].Backends["be-v4"]; !ok { + t.Error("web4 primary pool missing be-v4") + } + if web4.Pools[0].Backends["be-v4"].Weight != 100 { + t.Errorf("web4 be-v4 weight: got %d, want 100 (default)", web4.Pools[0].Backends["be-v4"].Weight) + } + web6 := cfg.Frontends["web6"] - if len(web6.Backends) != 2 { - t.Errorf("web6 backends: got %d, want 2", len(web6.Backends)) + if len(web6.Pools) != 1 || len(web6.Pools[0].Backends) != 2 { + t.Errorf("web6 pools[0] backends: got %d, want 2", len(web6.Pools[0].Backends)) + } + if web6.Pools[0].Backends["be-v6b"].Weight != 50 { + t.Errorf("web6 be-v6b weight: got %d, want 50", web6.Pools[0].Backends["be-v6b"].Weight) } } @@ -152,7 +162,10 @@ maglev: frontends: v: address: 192.0.2.1 - backends: [be] + pools: + - name: primary + backends: + be: {} ` cfg, err := parse([]byte(raw)) if err != nil { @@ -169,8 +182,13 @@ maglev: t.Errorf("defaults rise/fall: got %d/%d, want 2/3", hc.Rise, hc.Fall) } be := cfg.Backends["be"] - if !be.Enabled || be.Weight != 100 { - t.Errorf("backend defaults: enabled=%v weight=%d", be.Enabled, be.Weight) + if !be.Enabled { + t.Errorf("backend default enabled: got false, want true") + } + // Pool backend weight defaults to 100. + v := cfg.Frontends["v"] + if v.Pools[0].Backends["be"].Weight != 100 { + t.Errorf("pool backend default weight: got %d, want 100", v.Pools[0].Backends["be"].Weight) } } @@ -185,7 +203,10 @@ maglev: frontends: v: address: 192.0.2.1 - backends: [be] + pools: + - name: primary + backends: + be: {} ` cfg, err := parse([]byte(raw)) if err != nil { @@ -213,7 +234,10 @@ maglev: frontends: v: address: 192.0.2.1 - backends: [be] + pools: + - name: primary + backends: + be: {} ` cfg, err := parse([]byte(raw)) if err != nil { @@ -249,7 +273,10 @@ maglev: frontends: v: address: 192.0.2.1 - backends: [be] + pools: + - name: primary + backends: + be: {} ` + feExtra } @@ -264,7 +291,7 @@ maglev: errSub: "probe-ipv4-src", }, { - name: "mixed backend address families in frontend", + name: "mixed backend address families in pool", yaml: ` maglev: healthchecks: @@ -278,7 +305,11 @@ maglev: frontends: v: address: 192.0.2.1 - backends: [v4, v6] + pools: + - name: primary + backends: + v4: {} + v6: {} `, errSub: "address family", }, @@ -302,7 +333,10 @@ maglev: v: address: 192.0.2.1 protocol: tcp - backends: [be] + pools: + - name: primary + backends: + be: {} `, errSub: "requires port", }, @@ -320,7 +354,10 @@ maglev: frontends: v: address: 192.0.2.1 - backends: [be] + pools: + - name: primary + backends: + be: {} `, errSub: "type must be", }, @@ -339,12 +376,15 @@ maglev: frontends: v: address: 192.0.2.1 - backends: [be] + pools: + - name: primary + backends: + be: {} `, errSub: "params.path", }, { - name: "negative interval", + name: "no error case", yaml: base("", "", ""), errSub: "", }, @@ -358,12 +398,15 @@ maglev: frontends: v: address: 192.0.2.1 - backends: [be] + pools: + - name: primary + backends: + be: {} `, errSub: "not defined", }, { - name: "undefined backend reference in frontend", + name: "undefined backend reference in pool", yaml: ` maglev: healthchecks: @@ -375,13 +418,33 @@ maglev: frontends: v: address: 192.0.2.1 - backends: [missing] + pools: + - name: primary + backends: + missing: {} `, errSub: "not defined", }, { - name: "weight out of range", - yaml: base("", " weight: 150\n", ""), + name: "pool weight out of range", + yaml: ` +maglev: + healthchecks: + c: + type: icmp + interval: 1s + timeout: 2s + backends: + be: {address: 10.0.0.2, healthcheck: c} + frontends: + v: + address: 192.0.2.1 + pools: + - name: primary + backends: + be: + weight: 150 +`, errSub: "out of range", }, { @@ -403,7 +466,10 @@ maglev: frontends: v: address: 192.0.2.1 - backends: [be] + pools: + - name: primary + backends: + be: {} `, errSub: "requires port", }, @@ -423,10 +489,51 @@ maglev: frontends: v: address: 192.0.2.1 - backends: [be] + pools: + - name: primary + backends: + be: {} `, errSub: "requires port", }, + { + name: "empty pools", + yaml: ` +maglev: + healthchecks: + c: + type: icmp + interval: 1s + timeout: 2s + backends: + be: {address: 10.0.0.2, healthcheck: c} + frontends: + v: + address: 192.0.2.1 + pools: [] +`, + errSub: "pools must not be empty", + }, + { + name: "pool missing name", + yaml: ` +maglev: + healthchecks: + c: + type: icmp + interval: 1s + timeout: 2s + backends: + be: {address: 10.0.0.2, healthcheck: c} + frontends: + v: + address: 192.0.2.1 + pools: + - backends: + be: {} +`, + errSub: "name must not be empty", + }, } for _, tt := range tests { diff --git a/internal/grpcapi/maglev.pb.go b/internal/grpcapi/maglev.pb.go index 6d9c370..86da8cb 100644 --- a/internal/grpcapi/maglev.pb.go +++ b/internal/grpcapi/maglev.pb.go @@ -385,13 +385,117 @@ func (x *ListFrontendsResponse) GetFrontendNames() []string { return nil } +type PoolBackendInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Weight int32 `protobuf:"varint,2,opt,name=weight,proto3" json:"weight,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PoolBackendInfo) Reset() { + *x = PoolBackendInfo{} + mi := &file_proto_maglev_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PoolBackendInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PoolBackendInfo) ProtoMessage() {} + +func (x *PoolBackendInfo) ProtoReflect() protoreflect.Message { + mi := &file_proto_maglev_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PoolBackendInfo.ProtoReflect.Descriptor instead. +func (*PoolBackendInfo) Descriptor() ([]byte, []int) { + return file_proto_maglev_proto_rawDescGZIP(), []int{9} +} + +func (x *PoolBackendInfo) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *PoolBackendInfo) GetWeight() int32 { + if x != nil { + return x.Weight + } + return 0 +} + +type PoolInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Backends []*PoolBackendInfo `protobuf:"bytes,2,rep,name=backends,proto3" json:"backends,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PoolInfo) Reset() { + *x = PoolInfo{} + mi := &file_proto_maglev_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PoolInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PoolInfo) ProtoMessage() {} + +func (x *PoolInfo) ProtoReflect() protoreflect.Message { + mi := &file_proto_maglev_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PoolInfo.ProtoReflect.Descriptor instead. +func (*PoolInfo) Descriptor() ([]byte, []int) { + return file_proto_maglev_proto_rawDescGZIP(), []int{10} +} + +func (x *PoolInfo) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *PoolInfo) GetBackends() []*PoolBackendInfo { + if x != nil { + return x.Backends + } + return nil +} + type FrontendInfo struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` Address string `protobuf:"bytes,2,opt,name=address,proto3" json:"address,omitempty"` Protocol string `protobuf:"bytes,3,opt,name=protocol,proto3" json:"protocol,omitempty"` Port uint32 `protobuf:"varint,4,opt,name=port,proto3" json:"port,omitempty"` - BackendNames []string `protobuf:"bytes,5,rep,name=backend_names,json=backendNames,proto3" json:"backend_names,omitempty"` + Pools []*PoolInfo `protobuf:"bytes,5,rep,name=pools,proto3" json:"pools,omitempty"` Description string `protobuf:"bytes,6,opt,name=description,proto3" json:"description,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -399,7 +503,7 @@ type FrontendInfo struct { func (x *FrontendInfo) Reset() { *x = FrontendInfo{} - mi := &file_proto_maglev_proto_msgTypes[9] + mi := &file_proto_maglev_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -411,7 +515,7 @@ func (x *FrontendInfo) String() string { func (*FrontendInfo) ProtoMessage() {} func (x *FrontendInfo) ProtoReflect() protoreflect.Message { - mi := &file_proto_maglev_proto_msgTypes[9] + mi := &file_proto_maglev_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -424,7 +528,7 @@ func (x *FrontendInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use FrontendInfo.ProtoReflect.Descriptor instead. func (*FrontendInfo) Descriptor() ([]byte, []int) { - return file_proto_maglev_proto_rawDescGZIP(), []int{9} + return file_proto_maglev_proto_rawDescGZIP(), []int{11} } func (x *FrontendInfo) GetName() string { @@ -455,9 +559,9 @@ func (x *FrontendInfo) GetPort() uint32 { return 0 } -func (x *FrontendInfo) GetBackendNames() []string { +func (x *FrontendInfo) GetPools() []*PoolInfo { if x != nil { - return x.BackendNames + return x.Pools } return nil } @@ -478,7 +582,7 @@ type ListBackendsResponse struct { func (x *ListBackendsResponse) Reset() { *x = ListBackendsResponse{} - mi := &file_proto_maglev_proto_msgTypes[10] + mi := &file_proto_maglev_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -490,7 +594,7 @@ func (x *ListBackendsResponse) String() string { func (*ListBackendsResponse) ProtoMessage() {} func (x *ListBackendsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_maglev_proto_msgTypes[10] + mi := &file_proto_maglev_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -503,7 +607,7 @@ func (x *ListBackendsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListBackendsResponse.ProtoReflect.Descriptor instead. func (*ListBackendsResponse) Descriptor() ([]byte, []int) { - return file_proto_maglev_proto_rawDescGZIP(), []int{10} + return file_proto_maglev_proto_rawDescGZIP(), []int{12} } func (x *ListBackendsResponse) GetBackendNames() []string { @@ -522,7 +626,7 @@ type ListHealthChecksResponse struct { func (x *ListHealthChecksResponse) Reset() { *x = ListHealthChecksResponse{} - mi := &file_proto_maglev_proto_msgTypes[11] + mi := &file_proto_maglev_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -534,7 +638,7 @@ func (x *ListHealthChecksResponse) String() string { func (*ListHealthChecksResponse) ProtoMessage() {} func (x *ListHealthChecksResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_maglev_proto_msgTypes[11] + mi := &file_proto_maglev_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -547,7 +651,7 @@ func (x *ListHealthChecksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListHealthChecksResponse.ProtoReflect.Descriptor instead. func (*ListHealthChecksResponse) Descriptor() ([]byte, []int) { - return file_proto_maglev_proto_rawDescGZIP(), []int{11} + return file_proto_maglev_proto_rawDescGZIP(), []int{13} } func (x *ListHealthChecksResponse) GetNames() []string { @@ -572,7 +676,7 @@ type HTTPCheckParams struct { func (x *HTTPCheckParams) Reset() { *x = HTTPCheckParams{} - mi := &file_proto_maglev_proto_msgTypes[12] + mi := &file_proto_maglev_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -584,7 +688,7 @@ func (x *HTTPCheckParams) String() string { func (*HTTPCheckParams) ProtoMessage() {} func (x *HTTPCheckParams) ProtoReflect() protoreflect.Message { - mi := &file_proto_maglev_proto_msgTypes[12] + mi := &file_proto_maglev_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -597,7 +701,7 @@ func (x *HTTPCheckParams) ProtoReflect() protoreflect.Message { // Deprecated: Use HTTPCheckParams.ProtoReflect.Descriptor instead. func (*HTTPCheckParams) Descriptor() ([]byte, []int) { - return file_proto_maglev_proto_rawDescGZIP(), []int{12} + return file_proto_maglev_proto_rawDescGZIP(), []int{14} } func (x *HTTPCheckParams) GetPath() string { @@ -660,7 +764,7 @@ type TCPCheckParams struct { func (x *TCPCheckParams) Reset() { *x = TCPCheckParams{} - mi := &file_proto_maglev_proto_msgTypes[13] + mi := &file_proto_maglev_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -672,7 +776,7 @@ func (x *TCPCheckParams) String() string { func (*TCPCheckParams) ProtoMessage() {} func (x *TCPCheckParams) ProtoReflect() protoreflect.Message { - mi := &file_proto_maglev_proto_msgTypes[13] + mi := &file_proto_maglev_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -685,7 +789,7 @@ func (x *TCPCheckParams) ProtoReflect() protoreflect.Message { // Deprecated: Use TCPCheckParams.ProtoReflect.Descriptor instead. func (*TCPCheckParams) Descriptor() ([]byte, []int) { - return file_proto_maglev_proto_rawDescGZIP(), []int{13} + return file_proto_maglev_proto_rawDescGZIP(), []int{15} } func (x *TCPCheckParams) GetSsl() bool { @@ -730,7 +834,7 @@ type HealthCheckInfo struct { func (x *HealthCheckInfo) Reset() { *x = HealthCheckInfo{} - mi := &file_proto_maglev_proto_msgTypes[14] + mi := &file_proto_maglev_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -742,7 +846,7 @@ func (x *HealthCheckInfo) String() string { func (*HealthCheckInfo) ProtoMessage() {} func (x *HealthCheckInfo) ProtoReflect() protoreflect.Message { - mi := &file_proto_maglev_proto_msgTypes[14] + mi := &file_proto_maglev_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -755,7 +859,7 @@ func (x *HealthCheckInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use HealthCheckInfo.ProtoReflect.Descriptor instead. func (*HealthCheckInfo) Descriptor() ([]byte, []int) { - return file_proto_maglev_proto_rawDescGZIP(), []int{14} + return file_proto_maglev_proto_rawDescGZIP(), []int{16} } func (x *HealthCheckInfo) GetName() string { @@ -856,15 +960,14 @@ type BackendInfo struct { State string `protobuf:"bytes,3,opt,name=state,proto3" json:"state,omitempty"` Transitions []*TransitionRecord `protobuf:"bytes,4,rep,name=transitions,proto3" json:"transitions,omitempty"` Enabled bool `protobuf:"varint,5,opt,name=enabled,proto3" json:"enabled,omitempty"` - Weight int32 `protobuf:"varint,6,opt,name=weight,proto3" json:"weight,omitempty"` - Healthcheck string `protobuf:"bytes,7,opt,name=healthcheck,proto3" json:"healthcheck,omitempty"` + Healthcheck string `protobuf:"bytes,6,opt,name=healthcheck,proto3" json:"healthcheck,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *BackendInfo) Reset() { *x = BackendInfo{} - mi := &file_proto_maglev_proto_msgTypes[15] + mi := &file_proto_maglev_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -876,7 +979,7 @@ func (x *BackendInfo) String() string { func (*BackendInfo) ProtoMessage() {} func (x *BackendInfo) ProtoReflect() protoreflect.Message { - mi := &file_proto_maglev_proto_msgTypes[15] + mi := &file_proto_maglev_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -889,7 +992,7 @@ func (x *BackendInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use BackendInfo.ProtoReflect.Descriptor instead. func (*BackendInfo) Descriptor() ([]byte, []int) { - return file_proto_maglev_proto_rawDescGZIP(), []int{15} + return file_proto_maglev_proto_rawDescGZIP(), []int{17} } func (x *BackendInfo) GetName() string { @@ -927,13 +1030,6 @@ func (x *BackendInfo) GetEnabled() bool { return false } -func (x *BackendInfo) GetWeight() int32 { - if x != nil { - return x.Weight - } - return 0 -} - func (x *BackendInfo) GetHealthcheck() string { if x != nil { return x.Healthcheck @@ -952,7 +1048,7 @@ type TransitionRecord struct { func (x *TransitionRecord) Reset() { *x = TransitionRecord{} - mi := &file_proto_maglev_proto_msgTypes[16] + mi := &file_proto_maglev_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -964,7 +1060,7 @@ func (x *TransitionRecord) String() string { func (*TransitionRecord) ProtoMessage() {} func (x *TransitionRecord) ProtoReflect() protoreflect.Message { - mi := &file_proto_maglev_proto_msgTypes[16] + mi := &file_proto_maglev_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -977,7 +1073,7 @@ func (x *TransitionRecord) ProtoReflect() protoreflect.Message { // Deprecated: Use TransitionRecord.ProtoReflect.Descriptor instead. func (*TransitionRecord) Descriptor() ([]byte, []int) { - return file_proto_maglev_proto_rawDescGZIP(), []int{16} + return file_proto_maglev_proto_rawDescGZIP(), []int{18} } func (x *TransitionRecord) GetFrom() string { @@ -1011,7 +1107,7 @@ type BackendEvent struct { func (x *BackendEvent) Reset() { *x = BackendEvent{} - mi := &file_proto_maglev_proto_msgTypes[17] + mi := &file_proto_maglev_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1023,7 +1119,7 @@ func (x *BackendEvent) String() string { func (*BackendEvent) ProtoMessage() {} func (x *BackendEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_maglev_proto_msgTypes[17] + mi := &file_proto_maglev_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1036,7 +1132,7 @@ func (x *BackendEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use BackendEvent.ProtoReflect.Descriptor instead. func (*BackendEvent) Descriptor() ([]byte, []int) { - return file_proto_maglev_proto_rawDescGZIP(), []int{17} + return file_proto_maglev_proto_rawDescGZIP(), []int{19} } func (x *BackendEvent) GetBackendName() string { @@ -1071,13 +1167,19 @@ const file_proto_maglev_proto_rawDesc = "" + "\x04name\x18\x01 \x01(\tR\x04name\"\x0e\n" + "\fWatchRequest\">\n" + "\x15ListFrontendsResponse\x12%\n" + - "\x0efrontend_names\x18\x01 \x03(\tR\rfrontendNames\"\xb3\x01\n" + + "\x0efrontend_names\x18\x01 \x03(\tR\rfrontendNames\"=\n" + + "\x0fPoolBackendInfo\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x16\n" + + "\x06weight\x18\x02 \x01(\x05R\x06weight\"S\n" + + "\bPoolInfo\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x123\n" + + "\bbackends\x18\x02 \x03(\v2\x17.maglev.PoolBackendInfoR\bbackends\"\xb6\x01\n" + "\fFrontendInfo\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + "\aaddress\x18\x02 \x01(\tR\aaddress\x12\x1a\n" + "\bprotocol\x18\x03 \x01(\tR\bprotocol\x12\x12\n" + - "\x04port\x18\x04 \x01(\rR\x04port\x12#\n" + - "\rbackend_names\x18\x05 \x03(\tR\fbackendNames\x12 \n" + + "\x04port\x18\x04 \x01(\rR\x04port\x12&\n" + + "\x05pools\x18\x05 \x03(\v2\x10.maglev.PoolInfoR\x05pools\x12 \n" + "\vdescription\x18\x06 \x01(\tR\vdescription\";\n" + "\x14ListBackendsResponse\x12#\n" + "\rbackend_names\x18\x01 \x03(\tR\fbackendNames\"0\n" + @@ -1113,15 +1215,14 @@ const file_proto_maglev_proto_rawDesc = "" + " \x01(\x05R\x04rise\x12\x12\n" + "\x04fall\x18\v \x01(\x05R\x04fall\x12+\n" + "\x04http\x18\f \x01(\v2\x17.maglev.HTTPCheckParamsR\x04http\x12(\n" + - "\x03tcp\x18\r \x01(\v2\x16.maglev.TCPCheckParamsR\x03tcp\"\xe1\x01\n" + + "\x03tcp\x18\r \x01(\v2\x16.maglev.TCPCheckParamsR\x03tcp\"\xc9\x01\n" + "\vBackendInfo\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + "\aaddress\x18\x02 \x01(\tR\aaddress\x12\x14\n" + "\x05state\x18\x03 \x01(\tR\x05state\x12:\n" + "\vtransitions\x18\x04 \x03(\v2\x18.maglev.TransitionRecordR\vtransitions\x12\x18\n" + - "\aenabled\x18\x05 \x01(\bR\aenabled\x12\x16\n" + - "\x06weight\x18\x06 \x01(\x05R\x06weight\x12 \n" + - "\vhealthcheck\x18\a \x01(\tR\vhealthcheck\"T\n" + + "\aenabled\x18\x05 \x01(\bR\aenabled\x12 \n" + + "\vhealthcheck\x18\x06 \x01(\tR\vhealthcheck\"T\n" + "\x10TransitionRecord\x12\x12\n" + "\x04from\x18\x01 \x01(\tR\x04from\x12\x0e\n" + "\x02to\x18\x02 \x01(\tR\x02to\x12\x1c\n" + @@ -1156,7 +1257,7 @@ func file_proto_maglev_proto_rawDescGZIP() []byte { return file_proto_maglev_proto_rawDescData } -var file_proto_maglev_proto_msgTypes = make([]protoimpl.MessageInfo, 18) +var file_proto_maglev_proto_msgTypes = make([]protoimpl.MessageInfo, 20) var file_proto_maglev_proto_goTypes = []any{ (*ListFrontendsRequest)(nil), // 0: maglev.ListFrontendsRequest (*GetFrontendRequest)(nil), // 1: maglev.GetFrontendRequest @@ -1167,44 +1268,48 @@ var file_proto_maglev_proto_goTypes = []any{ (*GetHealthCheckRequest)(nil), // 6: maglev.GetHealthCheckRequest (*WatchRequest)(nil), // 7: maglev.WatchRequest (*ListFrontendsResponse)(nil), // 8: maglev.ListFrontendsResponse - (*FrontendInfo)(nil), // 9: maglev.FrontendInfo - (*ListBackendsResponse)(nil), // 10: maglev.ListBackendsResponse - (*ListHealthChecksResponse)(nil), // 11: maglev.ListHealthChecksResponse - (*HTTPCheckParams)(nil), // 12: maglev.HTTPCheckParams - (*TCPCheckParams)(nil), // 13: maglev.TCPCheckParams - (*HealthCheckInfo)(nil), // 14: maglev.HealthCheckInfo - (*BackendInfo)(nil), // 15: maglev.BackendInfo - (*TransitionRecord)(nil), // 16: maglev.TransitionRecord - (*BackendEvent)(nil), // 17: maglev.BackendEvent + (*PoolBackendInfo)(nil), // 9: maglev.PoolBackendInfo + (*PoolInfo)(nil), // 10: maglev.PoolInfo + (*FrontendInfo)(nil), // 11: maglev.FrontendInfo + (*ListBackendsResponse)(nil), // 12: maglev.ListBackendsResponse + (*ListHealthChecksResponse)(nil), // 13: maglev.ListHealthChecksResponse + (*HTTPCheckParams)(nil), // 14: maglev.HTTPCheckParams + (*TCPCheckParams)(nil), // 15: maglev.TCPCheckParams + (*HealthCheckInfo)(nil), // 16: maglev.HealthCheckInfo + (*BackendInfo)(nil), // 17: maglev.BackendInfo + (*TransitionRecord)(nil), // 18: maglev.TransitionRecord + (*BackendEvent)(nil), // 19: maglev.BackendEvent } var file_proto_maglev_proto_depIdxs = []int32{ - 12, // 0: maglev.HealthCheckInfo.http:type_name -> maglev.HTTPCheckParams - 13, // 1: maglev.HealthCheckInfo.tcp:type_name -> maglev.TCPCheckParams - 16, // 2: maglev.BackendInfo.transitions:type_name -> maglev.TransitionRecord - 16, // 3: maglev.BackendEvent.transition:type_name -> maglev.TransitionRecord - 0, // 4: maglev.Maglev.ListFrontends:input_type -> maglev.ListFrontendsRequest - 1, // 5: maglev.Maglev.GetFrontend:input_type -> maglev.GetFrontendRequest - 2, // 6: maglev.Maglev.ListBackends:input_type -> maglev.ListBackendsRequest - 3, // 7: maglev.Maglev.GetBackend:input_type -> maglev.GetBackendRequest - 4, // 8: maglev.Maglev.PauseBackend:input_type -> maglev.PauseResumeRequest - 4, // 9: maglev.Maglev.ResumeBackend:input_type -> maglev.PauseResumeRequest - 5, // 10: maglev.Maglev.ListHealthChecks:input_type -> maglev.ListHealthChecksRequest - 6, // 11: maglev.Maglev.GetHealthCheck:input_type -> maglev.GetHealthCheckRequest - 7, // 12: maglev.Maglev.WatchBackendEvents:input_type -> maglev.WatchRequest - 8, // 13: maglev.Maglev.ListFrontends:output_type -> maglev.ListFrontendsResponse - 9, // 14: maglev.Maglev.GetFrontend:output_type -> maglev.FrontendInfo - 10, // 15: maglev.Maglev.ListBackends:output_type -> maglev.ListBackendsResponse - 15, // 16: maglev.Maglev.GetBackend:output_type -> maglev.BackendInfo - 15, // 17: maglev.Maglev.PauseBackend:output_type -> maglev.BackendInfo - 15, // 18: maglev.Maglev.ResumeBackend:output_type -> maglev.BackendInfo - 11, // 19: maglev.Maglev.ListHealthChecks:output_type -> maglev.ListHealthChecksResponse - 14, // 20: maglev.Maglev.GetHealthCheck:output_type -> maglev.HealthCheckInfo - 17, // 21: maglev.Maglev.WatchBackendEvents:output_type -> maglev.BackendEvent - 13, // [13:22] is the sub-list for method output_type - 4, // [4:13] is the sub-list for method input_type - 4, // [4:4] is the sub-list for extension type_name - 4, // [4:4] is the sub-list for extension extendee - 0, // [0:4] is the sub-list for field type_name + 9, // 0: maglev.PoolInfo.backends:type_name -> maglev.PoolBackendInfo + 10, // 1: maglev.FrontendInfo.pools:type_name -> maglev.PoolInfo + 14, // 2: maglev.HealthCheckInfo.http:type_name -> maglev.HTTPCheckParams + 15, // 3: maglev.HealthCheckInfo.tcp:type_name -> maglev.TCPCheckParams + 18, // 4: maglev.BackendInfo.transitions:type_name -> maglev.TransitionRecord + 18, // 5: maglev.BackendEvent.transition:type_name -> maglev.TransitionRecord + 0, // 6: maglev.Maglev.ListFrontends:input_type -> maglev.ListFrontendsRequest + 1, // 7: maglev.Maglev.GetFrontend:input_type -> maglev.GetFrontendRequest + 2, // 8: maglev.Maglev.ListBackends:input_type -> maglev.ListBackendsRequest + 3, // 9: maglev.Maglev.GetBackend:input_type -> maglev.GetBackendRequest + 4, // 10: maglev.Maglev.PauseBackend:input_type -> maglev.PauseResumeRequest + 4, // 11: maglev.Maglev.ResumeBackend:input_type -> maglev.PauseResumeRequest + 5, // 12: maglev.Maglev.ListHealthChecks:input_type -> maglev.ListHealthChecksRequest + 6, // 13: maglev.Maglev.GetHealthCheck:input_type -> maglev.GetHealthCheckRequest + 7, // 14: maglev.Maglev.WatchBackendEvents:input_type -> maglev.WatchRequest + 8, // 15: maglev.Maglev.ListFrontends:output_type -> maglev.ListFrontendsResponse + 11, // 16: maglev.Maglev.GetFrontend:output_type -> maglev.FrontendInfo + 12, // 17: maglev.Maglev.ListBackends:output_type -> maglev.ListBackendsResponse + 17, // 18: maglev.Maglev.GetBackend:output_type -> maglev.BackendInfo + 17, // 19: maglev.Maglev.PauseBackend:output_type -> maglev.BackendInfo + 17, // 20: maglev.Maglev.ResumeBackend:output_type -> maglev.BackendInfo + 13, // 21: maglev.Maglev.ListHealthChecks:output_type -> maglev.ListHealthChecksResponse + 16, // 22: maglev.Maglev.GetHealthCheck:output_type -> maglev.HealthCheckInfo + 19, // 23: maglev.Maglev.WatchBackendEvents:output_type -> maglev.BackendEvent + 15, // [15:24] is the sub-list for method output_type + 6, // [6:15] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name } func init() { file_proto_maglev_proto_init() } @@ -1218,7 +1323,7 @@ func file_proto_maglev_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_maglev_proto_rawDesc), len(file_proto_maglev_proto_rawDesc)), NumEnums: 0, - NumMessages: 18, + NumMessages: 20, NumExtensions: 0, NumServices: 1, }, diff --git a/internal/grpcapi/server.go b/internal/grpcapi/server.go index ab55b69..3036012 100644 --- a/internal/grpcapi/server.go +++ b/internal/grpcapi/server.go @@ -137,13 +137,24 @@ func (s *Server) WatchBackendEvents(_ *WatchRequest, stream Maglev_WatchBackendE // ---- conversion helpers ---------------------------------------------------- func frontendToProto(name string, fe config.Frontend) *FrontendInfo { + pools := make([]*PoolInfo, 0, len(fe.Pools)) + for _, p := range fe.Pools { + pi := &PoolInfo{Name: p.Name} + for bName, pb := range p.Backends { + pi.Backends = append(pi.Backends, &PoolBackendInfo{ + Name: bName, + Weight: int32(pb.Weight), + }) + } + pools = append(pools, pi) + } return &FrontendInfo{ - Name: name, - Address: fe.Address.String(), - Protocol: fe.Protocol, - Port: uint32(fe.Port), - Description: fe.Description, - BackendNames: fe.Backends, + Name: name, + Address: fe.Address.String(), + Protocol: fe.Protocol, + Port: uint32(fe.Port), + Description: fe.Description, + Pools: pools, } } @@ -153,7 +164,6 @@ func backendToProto(snap checker.BackendSnapshot) *BackendInfo { Address: snap.Health.Address.String(), State: snap.Health.State.String(), Enabled: snap.Config.Enabled, - Weight: int32(snap.Config.Weight), Healthcheck: snap.Config.HealthCheck, } for _, t := range snap.Health.Transitions { diff --git a/internal/grpcapi/server_test.go b/internal/grpcapi/server_test.go index bf7fc14..51375f0 100644 --- a/internal/grpcapi/server_test.go +++ b/internal/grpcapi/server_test.go @@ -33,7 +33,6 @@ func makeTestChecker(ctx context.Context) *checker.Checker { Address: net.ParseIP("10.0.0.2"), HealthCheck: "icmp", Enabled: true, - Weight: 100, }, }, Frontends: map[string]config.Frontend{ @@ -41,7 +40,11 @@ func makeTestChecker(ctx context.Context) *checker.Checker { Address: net.ParseIP("192.0.2.1"), Protocol: "tcp", Port: 80, - Backends: []string{"be0"}, + Pools: []config.Pool{ + {Name: "primary", Backends: map[string]config.PoolBackend{ + "be0": {Weight: 100}, + }}, + }, }, }, } @@ -108,8 +111,14 @@ func TestGetFrontend(t *testing.T) { if info.Port != 80 { t.Errorf("GetFrontend port: got %d, want 80", info.Port) } - if len(info.BackendNames) != 1 || info.BackendNames[0] != "be0" { - t.Errorf("GetFrontend backend_names: got %v, want [be0]", info.BackendNames) + if len(info.Pools) != 1 || info.Pools[0].Name != "primary" { + t.Errorf("GetFrontend pools: got %v, want [{primary [be0]}]", info.Pools) + } + if len(info.Pools[0].Backends) != 1 || info.Pools[0].Backends[0].Name != "be0" { + t.Errorf("GetFrontend pools[0].backends: got %v, want [{be0 100}]", info.Pools[0].Backends) + } + if info.Pools[0].Backends[0].Weight != 100 { + t.Errorf("GetFrontend pools[0].backends[0].weight: got %d, want 100", info.Pools[0].Backends[0].Weight) } } @@ -162,9 +171,6 @@ func TestGetBackend(t *testing.T) { if !info.Enabled { t.Error("expected enabled=true") } - if info.Weight != 100 { - t.Errorf("weight: got %d, want 100", info.Weight) - } if info.Healthcheck != "icmp" { t.Errorf("healthcheck: got %q, want icmp", info.Healthcheck) } diff --git a/internal/prober/http.go b/internal/prober/http.go index fe2f8c4..b94be8a 100644 --- a/internal/prober/http.go +++ b/internal/prober/http.go @@ -40,11 +40,11 @@ func doHTTPProbe(ctx context.Context, cfg ProbeConfig, useTLS bool) health.Probe } } - scheme := "http" - if useTLS { - scheme = "https" - } - target := fmt.Sprintf("%s://%s%s", scheme, net.JoinHostPort(cfg.Target.String(), strconv.Itoa(int(port))), p.Path) + // Always use "http" scheme: TLS (if any) is already applied to conn during + // the netns dial phase. Using "https" here would cause http.Transport to + // wrap conn in a second TLS layer, producing "http: server gave HTTP + // response to HTTPS client". + target := fmt.Sprintf("http://%s%s", net.JoinHostPort(cfg.Target.String(), strconv.Itoa(int(port))), p.Path) hostHeader := p.Host if hostHeader == "" { diff --git a/internal/prober/http_test.go b/internal/prober/http_test.go index c95a97c..4125d61 100644 --- a/internal/prober/http_test.go +++ b/internal/prober/http_test.go @@ -170,6 +170,42 @@ func TestHTTPProbeRegexpNoMatch(t *testing.T) { } } +func TestHTTPSProbe(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + host, portStr, _ := net.SplitHostPort(srv.Listener.Addr().String()) + port := uint16(0) + fmt.Sscanf(portStr, "%d", &port) + + cfg := ProbeConfig{ + Target: net.ParseIP(host), + Port: port, + Timeout: 2 * time.Second, + HTTP: &config.HTTPParams{ + Path: "/", + ResponseCodeMin: 200, + ResponseCodeMax: 200, + InsecureSkipVerify: true, + }, + } + + // Verify HTTPSProbe succeeds (TLS conn reused, no double-wrap). + result := HTTPSProbe(context.Background(), cfg) + if !result.OK { + t.Errorf("HTTPSProbe failed: code=%s detail=%s", result.Code, result.Detail) + } + + // Verify HTTPProbe (plain) against the TLS server fails at the TLS layer, + // not with a double-TLS confusion error. + result = HTTPProbe(context.Background(), cfg) + if result.OK { + t.Error("plain HTTPProbe against TLS server should fail") + } +} + func TestHTTPProbeNoRedirect(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/other", http.StatusFound) diff --git a/proto/maglev.proto b/proto/maglev.proto index a4c3b95..3ae43a5 100644 --- a/proto/maglev.proto +++ b/proto/maglev.proto @@ -49,12 +49,22 @@ message ListFrontendsResponse { repeated string frontend_names = 1; } +message PoolBackendInfo { + string name = 1; + int32 weight = 2; +} + +message PoolInfo { + string name = 1; + repeated PoolBackendInfo backends = 2; +} + message FrontendInfo { string name = 1; string address = 2; string protocol = 3; uint32 port = 4; - repeated string backend_names = 5; + repeated PoolInfo pools = 5; string description = 6; } @@ -104,8 +114,7 @@ message BackendInfo { string state = 3; repeated TransitionRecord transitions = 4; bool enabled = 5; - int32 weight = 6; - string healthcheck = 7; + string healthcheck = 6; } message TransitionRecord {