Pools, CLI, versioning, Debian packaging, HTTPS fix

- Replaced flat `backends: [...]` list on frontends with an ordered `pools:`
  list; each pool has a name and a map of backends with per-pool weights (0–100,
  default 100). Pools express priority: first pool with a healthy backend wins.
- Removed global backend weight (was on the backend, now lives in the pool).
- Config validation enforces non-empty pools, non-empty pool names, weight
  range, and consistent address families across all pools of a frontend.

- Added `PoolBackendInfo { name, weight }` and changed `PoolInfo.backends` from
  `repeated string` to `repeated PoolBackendInfo` so weights are visible over
  the API.

- Full interactive shell with readline, tab completion, and `?` inline help.
- Command tree parser (Walk) handles fixed keywords and dynamic slot nodes;
  prefix matching with exact-match priority.
- Commands: `show version/frontends/frontend/backends/backend/healthchecks/
  healthcheck`, `set backend <name> pause|resume`, `quit`/`exit`.
- `show frontend` output is hierarchical (pools → backends) with per-backend
  weights and `[disabled]` notation; pool section uses fixed-width formatting
  so ANSI color codes don't corrupt tabwriter alignment.
- `-color` flag (default true) wraps static field labels in dark-blue ANSI;
  works correctly with tabwriter because all labels carry identical-length
  escape sequences.

- `cmd/version.go` package holds `version`, `commit`, `date` vars set at build
  time via `-ldflags -X`.
- `make build` / `make build-amd64` / `make build-arm64` all inject
  `VERSION=0.1.1`, `COMMIT_HASH` (from `git rev-parse --short HEAD`), and
  `DATE` (UTC ISO-8601).
- `maglevc` prints version on interactive startup and exposes `show version`.
- `maglevd` logs version/commit/date at startup; `-version` flag prints and exits.

- `doHTTPProbe` was building a `https://` target URL even though TLS was already
  applied to the connection inside `inNetns`. `http.Transport` then wrapped the
  connection in a second TLS layer, producing "http: server gave HTTP response
  to HTTPS client". Fixed by always using `http://` in the target URL.
- Added `TestHTTPSProbe` using `httptest.NewTLSServer` to cover the full path.

- New `docs/user-guide.md`: maglevd flags/signals, maglevc commands, shell
  completion, and command-tree parser walkthrough.
- New `docs/healthchecks.md`: state machine, rise/fall model, probe intervals,
  all transition events with log examples.
- Updated `docs/config-guide.md`: pools design, removed global weight from
  backends, updated all examples.
- Updated `README.md`: packaging table, build paths, corrected binary locations
  (`/usr/sbin/maglevd`), config filename (`.yaml`).

- `debian/` directory contains `control.in`, `maglevd.service`, `default.maglev`,
  `maglev.yaml` (example config), `conffiles`, `postinst`, `prerm`.
- `debian/build-deb.sh` stages a package tree and calls `dpkg-deb`; emits
  `build/vpp-maglev_<version>~<commit>_<arch>.deb`.
- Cross-compiles for amd64 and arm64 in one `make pkg-deb` invocation.
- `maglevd` installed to `/usr/sbin/`, `maglevc` to `/usr/bin/`.
- Service reads `MAGLEV_CONFIG` from `/etc/default/maglev`
  (default: `/etc/maglev/maglev.yaml`).
- Man pages `maglevd(8)` and `maglevc(1)` live in `docs/` and are gzip'd into
  the package.
- All build output goes to `build/<arch>/`; `build/` is gitignored.
This commit is contained in:
2026-04-11 12:18:17 +02:00
parent ad7d7e20fc
commit d612086a5f
31 changed files with 1471 additions and 282 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,3 @@
bin/ build/
/*.yaml /*.yaml
docs/implementation/ docs/implementation/

View File

@@ -1,16 +1,39 @@
BINARIES := maglevd maglevc BINARIES := maglevd maglevc
MODULE := git.ipng.ch/ipng/vpp-maglev MODULE := git.ipng.ch/ipng/vpp-maglev
PROTO_DIR := proto PROTO_DIR := proto
PROTO_FILE := $(PROTO_DIR)/maglev.proto PROTO_FILE := $(PROTO_DIR)/maglev.proto
GEN_FILES := internal/grpcapi/maglev.pb.go internal/grpcapi/maglev_grpc.pb.go 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 all: build
build: $(GEN_FILES) build: $(GEN_FILES)
go build -o bin/maglevd ./cmd/maglevd/ mkdir -p build/$(NATIVE_ARCH)
go build -o bin/maglevc ./cmd/maglevc/ 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) test: $(GEN_FILES)
go test ./... go test ./...
@@ -27,5 +50,5 @@ lint:
golangci-lint run ./... golangci-lint run ./...
clean: clean:
rm -f $(addprefix bin/,$(BINARIES)) rm -rf build/
rm -f $(GEN_FILES) rm -f $(GEN_FILES)

View File

@@ -5,29 +5,58 @@ Health checker and gRPC control plane for VPP Maglev load balancing.
## Build ## Build
```sh ```sh
make # builds bin/maglevd make # builds build/<arch>/maglevd and build/<arch>/maglevc
make test # runs all tests make test # runs all tests
make proto # regenerates gRPC stubs from proto/maglev.proto 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_<version>_amd64.deb` and `vpp-maglev_<version>_arm64.deb`
in the project root by cross-compiling with `GOOS=linux GOARCH=<arch>`.
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 ## Run
```sh ```sh
maglevd --config /etc/maglev/maglev.yaml --grpc-addr :9090 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 | Send `SIGHUP` to `maglevd` to reload config without restarting.
|---|---|---| `maglevd` requires `CAP_NET_RAW` for ICMP health checks.
| `--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.
## Minimal config ## Minimal config
@@ -51,10 +80,16 @@ maglev:
address: 192.0.2.1 address: 192.0.2.1
protocol: tcp protocol: tcp
port: 80 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/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 ## Docker

22
cmd/maglevc/color.go Normal file
View File

@@ -0,0 +1,22 @@
// Copyright (c) 2026, Pim van Pelt <pim@ipng.ch>
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
}

View File

@@ -10,6 +10,7 @@ import (
"text/tabwriter" "text/tabwriter"
"time" "time"
buildinfo "git.ipng.ch/ipng/vpp-maglev/cmd"
"git.ipng.ch/ipng/vpp-maglev/internal/grpcapi" "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} quit := &Node{Word: "quit", Help: "exit the shell", Run: runQuit}
exit := &Node{Word: "exit", 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 // show frontends
showFrontends := &Node{Word: "frontends", Help: "list all frontends", Run: runShowFrontends} showFrontends := &Node{Word: "frontends", Help: "list all frontends", Run: runShowFrontends}
// show frontend <name> // show frontend <name>
@@ -70,6 +74,7 @@ func buildTree() *Node {
} }
show.Children = []*Node{ show.Children = []*Node{
showVersion,
showFrontends, showFrontend, showFrontends, showFrontend,
showBackends, showBackend, showBackends, showBackend,
showHealthChecks, showHealthCheck, showHealthChecks, showHealthCheck,
@@ -125,6 +130,12 @@ func dynHealthChecks(ctx context.Context, client grpcapi.MaglevClient) []string
// ---- run functions --------------------------------------------------------- // ---- 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 { func runQuit(_ context.Context, _ grpcapi.MaglevClient, _ []string) error {
return errQuit return errQuit
} }
@@ -152,22 +163,53 @@ func runShowFrontend(ctx context.Context, client grpcapi.MaglevClient, args []st
if err != nil { if err != nil {
return err return err
} }
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintf(w, "name\t%s\n", info.Name) fmt.Fprintf(w, "%s\t%s\n", label("name"), info.Name)
fmt.Fprintf(w, "address\t%s\n", info.Address) fmt.Fprintf(w, "%s\t%s\n", label("address"), info.Address)
fmt.Fprintf(w, "protocol\t%s\n", info.Protocol) fmt.Fprintf(w, "%s\t%s\n", label("protocol"), info.Protocol)
fmt.Fprintf(w, "port\t%d\n", info.Port) fmt.Fprintf(w, "%s\t%d\n", label("port"), info.Port)
for i, b := range info.BackendNames { if info.Description != "" {
if i == 0 { fmt.Fprintf(w, "%s\t%s\n", label("description"), info.Description)
fmt.Fprintf(w, "backends\t%s\n", b) }
} else { if len(info.Pools) > 0 {
fmt.Fprintf(w, "\t%s\n", b) 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 != "" { return nil
fmt.Fprintf(w, "description\t%s\n", info.Description)
}
return w.Flush()
} }
func runShowBackends(ctx context.Context, client grpcapi.MaglevClient, _ []string) error { 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 return err
} }
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintf(w, "name\t%s\n", info.Name) fmt.Fprintf(w, "%s\t%s\n", label("name"), info.Name)
fmt.Fprintf(w, "address\t%s\n", info.Address) fmt.Fprintf(w, "%s\t%s\n", label("address"), info.Address)
stateDur := "" stateDur := ""
if len(info.Transitions) > 0 { if len(info.Transitions) > 0 {
since := time.Since(time.Unix(0, info.Transitions[0].AtUnixNs)) since := time.Since(time.Unix(0, info.Transitions[0].AtUnixNs))
stateDur = " for " + formatDuration(since) stateDur = " for " + formatDuration(since)
} }
fmt.Fprintf(w, "state\t%s%s\n", info.State, stateDur) fmt.Fprintf(w, "%s\t%s%s\n", label("state"), info.State, stateDur)
fmt.Fprintf(w, "enabled\t%v\n", info.Enabled) fmt.Fprintf(w, "%s\t%v\n", label("enabled"), info.Enabled)
fmt.Fprintf(w, "weight\t%d\n", info.Weight) fmt.Fprintf(w, "%s\t%s\n", label("healthcheck"), info.Healthcheck)
fmt.Fprintf(w, "healthcheck\t%s\n", info.Healthcheck)
for i, t := range info.Transitions { for i, t := range info.Transitions {
ts := time.Unix(0, t.AtUnixNs) ts := time.Unix(0, t.AtUnixNs)
label := "" lbl := ""
if i == 0 { if i == 0 {
label = "transitions" lbl = label("transitions")
} }
fmt.Fprintf(w, "%s\t%s → %s\t%s\t%s\n", fmt.Fprintf(w, "%s\t%s → %s\t%s\t%s\n",
label, lbl,
t.From, t.To, t.From, t.To,
ts.Format("2006-01-02 15:04:05.000"), ts.Format("2006-01-02 15:04:05.000"),
formatAgo(time.Since(ts)), formatAgo(time.Since(ts)),
@@ -245,41 +286,41 @@ func runShowHealthCheck(ctx context.Context, client grpcapi.MaglevClient, args [
return err return err
} }
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintf(w, "name\t%s\n", info.Name) fmt.Fprintf(w, "%s\t%s\n", label("name"), info.Name)
fmt.Fprintf(w, "type\t%s\n", info.Type) fmt.Fprintf(w, "%s\t%s\n", label("type"), info.Type)
if info.Port > 0 { 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 { 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 { 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, "%s\t%s\n", label("timeout"), time.Duration(info.TimeoutNs))
fmt.Fprintf(w, "rise\t%d\n", info.Rise) fmt.Fprintf(w, "%s\t%d\n", label("rise"), info.Rise)
fmt.Fprintf(w, "fall\t%d\n", info.Fall) fmt.Fprintf(w, "%s\t%d\n", label("fall"), info.Fall)
if info.ProbeIpv4Src != "" { 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 != "" { 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 { 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 != "" { 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 != "" { 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 { 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 != "" { 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() return w.Flush()

View File

@@ -12,6 +12,7 @@ import (
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
buildinfo "git.ipng.ch/ipng/vpp-maglev/cmd"
"git.ipng.ch/ipng/vpp-maglev/internal/grpcapi" "git.ipng.ch/ipng/vpp-maglev/internal/grpcapi"
) )
@@ -24,7 +25,9 @@ func main() {
func run() error { func run() error {
serverAddr := flag.String("server", "localhost:9090", "maglev server address") serverAddr := flag.String("server", "localhost:9090", "maglev server address")
color := flag.Bool("color", true, "colorize static labels in output")
flag.Parse() flag.Parse()
colorEnabled = *color
conn, err := grpc.NewClient(*serverAddr, conn, err := grpc.NewClient(*serverAddr,
grpc.WithTransportCredentials(insecure.NewCredentials())) grpc.WithTransportCredentials(insecure.NewCredentials()))
@@ -38,7 +41,9 @@ func run() error {
args := flag.Args() args := flag.Args()
if len(args) == 0 { 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) return runShell(ctx, client)
} }

View File

@@ -14,6 +14,7 @@ import (
"google.golang.org/grpc" "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/checker"
"git.ipng.ch/ipng/vpp-maglev/internal/config" "git.ipng.ch/ipng/vpp-maglev/internal/config"
"git.ipng.ch/ipng/vpp-maglev/internal/grpcapi" "git.ipng.ch/ipng/vpp-maglev/internal/grpcapi"
@@ -28,17 +29,25 @@ func main() {
func run() error { func run() error {
// ---- flags / env -------------------------------------------------------- // ---- flags / env --------------------------------------------------------
printVersion := flag.Bool("version", false, "print version and exit")
configPath := stringFlag("config", "/etc/maglev/frontend.yaml", "MAGLEV_CONFIG", "path to frontend.yaml") configPath := stringFlag("config", "/etc/maglev/frontend.yaml", "MAGLEV_CONFIG", "path to frontend.yaml")
grpcAddr := stringFlag("grpc-addr", ":9090", "MAGLEV_GRPC_ADDR", "gRPC listen address") 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)") logLevel := stringFlag("log-level", "info", "MAGLEV_LOG_LEVEL", "log level (debug|info|warn|error)")
flag.Parse() flag.Parse()
if *printVersion {
fmt.Printf("maglevd %s (commit %s, built %s)\n",
buildinfo.Version(), buildinfo.Commit(), buildinfo.Date())
return nil
}
// ---- logging ------------------------------------------------------------ // ---- logging ------------------------------------------------------------
var level slog.Level var level slog.Level
if err := level.UnmarshalText([]byte(*logLevel)); err != nil { if err := level.UnmarshalText([]byte(*logLevel)); err != nil {
return fmt.Errorf("invalid log level %q: %w", *logLevel, err) return fmt.Errorf("invalid log level %q: %w", *logLevel, err)
} }
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}))) 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 ------------------------------------------------------------- // ---- config -------------------------------------------------------------
cfg, err := config.Load(*configPath) cfg, err := config.Load(*configPath)

14
cmd/version.go Normal file
View File

@@ -0,0 +1,14 @@
// Copyright (c) 2026, Pim van Pelt <pim@ipng.ch>
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 }

58
debian/build-deb.sh vendored Executable file
View File

@@ -0,0 +1,58 @@
#!/bin/bash
# Build a vpp-maglev Debian package for one architecture.
# Usage: build-deb.sh <amd64|arm64> <version> <commit>
set -euo pipefail
ARCH="${1:?usage: build-deb.sh <amd64|arm64> <version> <commit>}"
VERSION="${2:?usage: build-deb.sh <amd64|arm64> <version> <commit>}"
COMMIT="${3:?usage: build-deb.sh <amd64|arm64> <version> <commit>}"
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"

2
debian/conffiles vendored Normal file
View File

@@ -0,0 +1,2 @@
/etc/default/maglev
/etc/maglev/maglev.yaml

14
debian/control.in vendored Normal file
View File

@@ -0,0 +1,14 @@
Package: vpp-maglev
Version: @VERSION@
Architecture: @ARCH@
Maintainer: Pim van Pelt <pim@ipng.ch>
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.

12
debian/default.maglev vendored Normal file
View File

@@ -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

56
debian/maglev.yaml vendored Normal file
View File

@@ -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: {}

15
debian/maglevd.service vendored Normal file
View File

@@ -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

8
debian/postinst vendored Normal file
View File

@@ -0,0 +1,8 @@
#!/bin/sh
set -e
case "$1" in
configure)
systemctl daemon-reload || true
systemctl enable maglevd.service || true
;;
esac

8
debian/prerm vendored Normal file
View File

@@ -0,0 +1,8 @@
#!/bin/sh
set -e
case "$1" in
remove|purge)
systemctl stop maglevd.service || true
systemctl disable maglevd.service || true
;;
esac

View File

@@ -77,12 +77,13 @@ Common fields (all types):
* ***probe-ipv6-src***: An optional IPv6 source address used when probing IPv6 backends. * ***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. 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 * ***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 * ***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 * ***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 backend is fully down (counter at zero). When omitted, `interval` is used. Setting this to
reduces probe traffic to backends that are known to be offline. 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 * ***timeout***: Required. A positive duration after which an in-flight probe is abandoned
and counted as a failure. and counted as a failure.
* ***rise***: The number of consecutive successes required to transition from down to up. * ***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. * ***enabled***: A boolean controlling whether this backend participates in any frontend.
When `false`, the backend is excluded entirely and no probe goroutine is started. When `false`, the backend is excluded entirely and no probe goroutine is started.
Defaults to `true`. 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: Examples:
```yaml ```yaml
@@ -206,7 +204,6 @@ backends:
nginx0-lon: nginx0-lon:
address: 198.51.100.11 address: 198.51.100.11
healthcheck: nginx-http healthcheck: nginx-http
weight: 50
nginx0-draining: nginx0-draining:
address: 198.51.100.12 address: 198.51.100.12
healthcheck: nginx-http healthcheck: nginx-http
@@ -220,8 +217,8 @@ backends:
## frontends ## frontends
A named map of virtual IPs (VIPs). Each frontend ties together a listener address with a set A named map of virtual IPs (VIPs). Each frontend ties together a listener address with an
of backends. The gRPC API exposes frontends by name. ordered list of backend pools. The gRPC API exposes frontends by name.
* ***description***: An optional free-text string for documentation purposes. * ***description***: An optional free-text string for documentation purposes.
* ***address***: Required. The IPv4 or IPv6 address of the VIP. * ***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 `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 frontend port is independent of the healthcheck port: a frontend on port 443 may use
a healthcheck that probes port 80. a healthcheck that probes port 80.
* ***backends***: Required. A non-empty list of backend names. All backends in a frontend * ***pools***: Required. A non-empty ordered list of pool objects. Pools express priority:
must have addresses of the same address family (all IPv4 or all IPv6). Every name must the first pool is preferred; subsequent pools act as fallbacks. All backends across all
refer to an existing entry in the `backends` section. 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: Examples:
```yaml ```yaml
frontends: frontends:
nginx-v4-http: nginx-v4-http:
description: "IPv4 HTTP VIP" description: "IPv4 HTTP VIP with fallback"
address: 198.51.100.1 address: 198.51.100.1
protocol: tcp protocol: tcp
port: 80 port: 80
backends: [nginx0-ams, nginx0-lon] pools:
- name: primary
nginx-v4-https: backends:
description: "IPv4 HTTPS VIP — reuses the same backends as HTTP" nginx0-ams: { weight: 10 }
address: 198.51.100.1 nginx0-lon: {}
protocol: tcp - name: fallback
port: 443 backends:
backends: [nginx0-ams, nginx0-lon] nginx0-fra: {}
maildrop-imaps: maildrop-imaps:
description: "IMAPS VIP" description: "IMAPS VIP"
address: 2001:db8::1 address: 2001:db8::1
protocol: tcp protocol: tcp
port: 993 port: 993
backends: [maildrop0-ams, maildrop0-lon] pools:
- name: primary
catchall: backends:
description: "Match all traffic to this VIP regardless of protocol or port" maildrop0-ams: {}
address: 198.51.100.2 maildrop0-lon: {}
backends: [static-backend]
``` ```
--- ---
@@ -322,7 +331,6 @@ maglev:
nginx0-fra: nginx0-fra:
address: 198.51.100.12 address: 198.51.100.12
healthcheck: nginx healthcheck: nginx
weight: 50
maildrop0-ams: maildrop0-ams:
address: 2001:db8:1::10 address: 2001:db8:1::10
healthcheck: dovecot healthcheck: dovecot
@@ -332,23 +340,46 @@ maglev:
frontends: frontends:
nginx-http: nginx-http:
description: "HTTP VIP" description: "HTTP VIP with fallback"
address: 198.51.100.1 address: 198.51.100.1
protocol: tcp protocol: tcp
port: 80 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: nginx-https:
description: "HTTPS VIP — same backends, different port" description: "HTTPS VIP — same backends, different port"
address: 198.51.100.1 address: 198.51.100.1
protocol: tcp protocol: tcp
port: 443 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: maildrop-imaps:
description: "IMAPS VIP" description: "IMAPS VIP"
address: 2001:db8::1 address: 2001:db8::1
protocol: tcp protocol: tcp
port: 993 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).

178
docs/healthchecks.md Normal file
View File

@@ -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 (rise1). 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
```
<any> → 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)
```
<any> → 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 (`<any> → 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.

112
docs/maglevc.1 Normal file
View File

@@ -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 <pim@ipng.ch>

85
docs/maglevd.8 Normal file
View File

@@ -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 <pim@ipng.ch>

133
docs/user-guide.md Normal file
View File

@@ -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 <name> 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 <name> 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 <name> Show full health-check configuration.
set backend <name> pause Suspend health checking for a backend, freezing its state.
set backend <name> 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 `<Tab>` 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 → <name>(nginx0-ams collected as arg) → pause
```
`pause.Run` is called with `args = ["nginx0-ams"]`.

View File

@@ -208,9 +208,16 @@ func (c *Checker) ListFrontendBackends(frontendName string) []*health.Backend {
return nil return nil
} }
var out []*health.Backend var out []*health.Backend
for _, name := range fe.Backends { seen := map[string]struct{}{}
if w, ok := c.workers[name]; ok { for _, pool := range fe.Pools {
out = append(out, w.backend) 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 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, // emitForBackend emits one Event per frontend that references backendName
// using the provided frontends map. Must be called with c.mu held. // (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) { func (c *Checker) emitForBackend(backendName string, addr net.IP, t health.Transition, frontends map[string]config.Frontend) {
for feName, fe := range frontends { for feName, fe := range frontends {
for _, name := range fe.Backends { emitted := false
if name == backendName { for _, pool := range fe.Pools {
c.emit(Event{FrontendName: feName, BackendName: backendName, Backend: addr, Transition: t}) if emitted {
break 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 // 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 { func activeBackendNames(cfg *config.Config) []string {
seen := map[string]struct{}{} seen := map[string]struct{}{}
for _, fe := range cfg.Frontends { for _, fe := range cfg.Frontends {
for _, name := range fe.Backends { for _, pool := range fe.Pools {
if b, ok := cfg.Backends[name]; ok && b.Enabled { for name := range pool.Backends {
seen[name] = struct{}{} if b, ok := cfg.Backends[name]; ok && b.Enabled {
seen[name] = struct{}{}
}
} }
} }
} }

View File

@@ -29,7 +29,6 @@ func makeTestConfig(interval time.Duration, fall, rise int) *config.Config {
Address: net.ParseIP("10.0.0.2"), Address: net.ParseIP("10.0.0.2"),
HealthCheck: "icmp", HealthCheck: "icmp",
Enabled: true, Enabled: true,
Weight: 100,
}, },
}, },
Frontends: map[string]config.Frontend{ 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"), Address: net.ParseIP("192.0.2.1"),
Protocol: "tcp", Protocol: "tcp",
Port: 80, 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"), Address: net.ParseIP("10.0.0.3"),
HealthCheck: "icmp", HealthCheck: "icmp",
Enabled: true, Enabled: true,
Weight: 100,
} }
newCfg.Frontends["web2"] = config.Frontend{ newCfg.Frontends["web2"] = config.Frontend{
Address: net.ParseIP("192.0.2.2"), Address: net.ParseIP("192.0.2.2"),
Protocol: "tcp", Protocol: "tcp",
Port: 443, Port: 443,
Backends: []string{"be1"}, Pools: []config.Pool{
{Name: "primary", Backends: map[string]config.PoolBackend{
"be1": {Weight: 100},
}},
},
} }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
@@ -186,7 +192,11 @@ func TestSharedBackendProbedOnce(t *testing.T) {
Address: net.ParseIP("192.0.2.3"), Address: net.ParseIP("192.0.2.3"),
Protocol: "tcp", Protocol: "tcp",
Port: 443, Port: 443,
Backends: []string{"be0"}, Pools: []config.Pool{
{Name: "primary", Backends: map[string]config.PoolBackend{
"be0": {Weight: 100},
}},
},
} }
c := New(cfg) c := New(cfg)

View File

@@ -67,16 +67,26 @@ type Backend struct {
Address net.IP Address net.IP
HealthCheck string // name reference into Config.HealthChecks; "" = no probing, assume healthy HealthCheck string // name reference into Config.HealthChecks; "" = no probing, assume healthy
Enabled bool // default true; false = exclude from serving entirely 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. // Frontend is a single virtual IP entry.
type Frontend struct { type Frontend struct {
Description string Description string
Address net.IP Address net.IP
Protocol string // "tcp", "udp", or "" (all traffic) Protocol string // "tcp", "udp", or "" (all traffic)
Port uint16 // 0 means omitted (all ports) Port uint16 // 0 means omitted (all ports)
Backends []string // backend names, each must exist in Config.Backends Pools []Pool // ordered tiers; first pool with any up backend is active
} }
// ---- raw YAML types -------------------------------------------------------- // ---- raw YAML types --------------------------------------------------------
@@ -127,15 +137,23 @@ type rawBackend struct {
Address string `yaml:"address"` Address string `yaml:"address"`
HealthCheck string `yaml:"healthcheck"` HealthCheck string `yaml:"healthcheck"`
Enabled *bool `yaml:"enabled"` // nil → default true 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 { type rawFrontend struct {
Description string `yaml:"description"` Description string `yaml:"description"`
Address string `yaml:"address"` Address string `yaml:"address"`
Protocol string `yaml:"protocol"` Protocol string `yaml:"protocol"`
Port uint16 `yaml:"port"` Port uint16 `yaml:"port"`
Backends []string `yaml:"backends"` Pools []rawPool `yaml:"pools"`
} }
// ---- Load ------------------------------------------------------------------ // ---- Load ------------------------------------------------------------------
@@ -319,11 +337,6 @@ func convertBackend(name string, r *rawBackend, hcs map[string]HealthCheck) (Bac
Address: ip, Address: ip,
HealthCheck: r.HealthCheck, HealthCheck: r.HealthCheck,
Enabled: boolDefault(r.Enabled, true), 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 != "" { if b.HealthCheck != "" {
@@ -340,7 +353,6 @@ func convertFrontend(name string, r *rawFrontend, backends map[string]Backend) (
Description: r.Description, Description: r.Description,
Protocol: r.Protocol, Protocol: r.Protocol,
Port: r.Port, Port: r.Port,
Backends: r.Backends,
} }
ip := net.ParseIP(r.Address) 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) return Frontend{}, fmt.Errorf("protocol %q requires port to be set (1-65535)", r.Protocol)
} }
if len(r.Backends) == 0 { if len(r.Pools) == 0 {
return Frontend{}, fmt.Errorf("backends must not be empty") return Frontend{}, fmt.Errorf("pools must not be empty")
} }
var firstFamily int var firstFamily int
for i, bName := range r.Backends { firstBackend := true
b, ok := backends[bName] for pi, rp := range r.Pools {
if !ok { if rp.Name == "" {
return Frontend{}, fmt.Errorf("backends[%d] %q not defined", i, bName) return Frontend{}, fmt.Errorf("pools[%d].name must not be empty", pi)
} }
fam := ipFamily(b.Address) if len(rp.Backends) == 0 {
if i == 0 { return Frontend{}, fmt.Errorf("pool %q backends must not be empty", rp.Name)
firstFamily = fam
} else if fam != firstFamily {
return Frontend{}, fmt.Errorf("backends[%d] %q has different address family than backends[0]", i, bName)
} }
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 return fe, nil

View File

@@ -41,7 +41,6 @@ maglev:
be-v6b: be-v6b:
address: 2001:db8:2::2 address: 2001:db8:2::2
healthcheck: icmp-check healthcheck: icmp-check
weight: 50
enabled: true enabled: true
frontends: frontends:
web4: web4:
@@ -49,13 +48,22 @@ maglev:
address: 192.0.2.1 address: 192.0.2.1
protocol: tcp protocol: tcp
port: 80 port: 80
backends: [be-v4] pools:
- name: primary
backends:
be-v4: {}
web6: web6:
description: "IPv6 VIP" description: "IPv6 VIP"
address: 2001:db8::1 address: 2001:db8::1
protocol: tcp protocol: tcp
port: 443 port: 443
backends: [be-v6a, be-v6b] pools:
- name: primary
backends:
be-v6a:
weight: 100
be-v6b:
weight: 50
` `
func TestValidConfig(t *testing.T) { 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) 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"] beV4 := cfg.Backends["be-v4"]
if beV4.Address.String() != "192.0.2.10" { if beV4.Address.String() != "192.0.2.10" {
t.Errorf("be-v4 address: got %s", beV4.Address) t.Errorf("be-v4 address: got %s", beV4.Address)
@@ -117,23 +125,25 @@ func TestValidConfig(t *testing.T) {
if !beV4.Enabled { if !beV4.Enabled {
t.Error("be-v4 enabled: want true (default)") 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"] // Pool structure.
if beV6b.Weight != 50 {
t.Errorf("be-v6b weight: got %d, want 50", beV6b.Weight)
}
// Frontend references.
web4 := cfg.Frontends["web4"] web4 := cfg.Frontends["web4"]
if len(web4.Backends) != 1 || web4.Backends[0] != "be-v4" { if len(web4.Pools) != 1 || web4.Pools[0].Name != "primary" {
t.Errorf("web4 backends: got %v", web4.Backends) 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"] web6 := cfg.Frontends["web6"]
if len(web6.Backends) != 2 { if len(web6.Pools) != 1 || len(web6.Pools[0].Backends) != 2 {
t.Errorf("web6 backends: got %d, want 2", len(web6.Backends)) 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: frontends:
v: v:
address: 192.0.2.1 address: 192.0.2.1
backends: [be] pools:
- name: primary
backends:
be: {}
` `
cfg, err := parse([]byte(raw)) cfg, err := parse([]byte(raw))
if err != nil { if err != nil {
@@ -169,8 +182,13 @@ maglev:
t.Errorf("defaults rise/fall: got %d/%d, want 2/3", hc.Rise, hc.Fall) t.Errorf("defaults rise/fall: got %d/%d, want 2/3", hc.Rise, hc.Fall)
} }
be := cfg.Backends["be"] be := cfg.Backends["be"]
if !be.Enabled || be.Weight != 100 { if !be.Enabled {
t.Errorf("backend defaults: enabled=%v weight=%d", be.Enabled, be.Weight) 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: frontends:
v: v:
address: 192.0.2.1 address: 192.0.2.1
backends: [be] pools:
- name: primary
backends:
be: {}
` `
cfg, err := parse([]byte(raw)) cfg, err := parse([]byte(raw))
if err != nil { if err != nil {
@@ -213,7 +234,10 @@ maglev:
frontends: frontends:
v: v:
address: 192.0.2.1 address: 192.0.2.1
backends: [be] pools:
- name: primary
backends:
be: {}
` `
cfg, err := parse([]byte(raw)) cfg, err := parse([]byte(raw))
if err != nil { if err != nil {
@@ -249,7 +273,10 @@ maglev:
frontends: frontends:
v: v:
address: 192.0.2.1 address: 192.0.2.1
backends: [be] pools:
- name: primary
backends:
be: {}
` + feExtra ` + feExtra
} }
@@ -264,7 +291,7 @@ maglev:
errSub: "probe-ipv4-src", errSub: "probe-ipv4-src",
}, },
{ {
name: "mixed backend address families in frontend", name: "mixed backend address families in pool",
yaml: ` yaml: `
maglev: maglev:
healthchecks: healthchecks:
@@ -278,7 +305,11 @@ maglev:
frontends: frontends:
v: v:
address: 192.0.2.1 address: 192.0.2.1
backends: [v4, v6] pools:
- name: primary
backends:
v4: {}
v6: {}
`, `,
errSub: "address family", errSub: "address family",
}, },
@@ -302,7 +333,10 @@ maglev:
v: v:
address: 192.0.2.1 address: 192.0.2.1
protocol: tcp protocol: tcp
backends: [be] pools:
- name: primary
backends:
be: {}
`, `,
errSub: "requires port", errSub: "requires port",
}, },
@@ -320,7 +354,10 @@ maglev:
frontends: frontends:
v: v:
address: 192.0.2.1 address: 192.0.2.1
backends: [be] pools:
- name: primary
backends:
be: {}
`, `,
errSub: "type must be", errSub: "type must be",
}, },
@@ -339,12 +376,15 @@ maglev:
frontends: frontends:
v: v:
address: 192.0.2.1 address: 192.0.2.1
backends: [be] pools:
- name: primary
backends:
be: {}
`, `,
errSub: "params.path", errSub: "params.path",
}, },
{ {
name: "negative interval", name: "no error case",
yaml: base("", "", ""), yaml: base("", "", ""),
errSub: "", errSub: "",
}, },
@@ -358,12 +398,15 @@ maglev:
frontends: frontends:
v: v:
address: 192.0.2.1 address: 192.0.2.1
backends: [be] pools:
- name: primary
backends:
be: {}
`, `,
errSub: "not defined", errSub: "not defined",
}, },
{ {
name: "undefined backend reference in frontend", name: "undefined backend reference in pool",
yaml: ` yaml: `
maglev: maglev:
healthchecks: healthchecks:
@@ -375,13 +418,33 @@ maglev:
frontends: frontends:
v: v:
address: 192.0.2.1 address: 192.0.2.1
backends: [missing] pools:
- name: primary
backends:
missing: {}
`, `,
errSub: "not defined", errSub: "not defined",
}, },
{ {
name: "weight out of range", name: "pool weight out of range",
yaml: base("", " weight: 150\n", ""), 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", errSub: "out of range",
}, },
{ {
@@ -403,7 +466,10 @@ maglev:
frontends: frontends:
v: v:
address: 192.0.2.1 address: 192.0.2.1
backends: [be] pools:
- name: primary
backends:
be: {}
`, `,
errSub: "requires port", errSub: "requires port",
}, },
@@ -423,10 +489,51 @@ maglev:
frontends: frontends:
v: v:
address: 192.0.2.1 address: 192.0.2.1
backends: [be] pools:
- name: primary
backends:
be: {}
`, `,
errSub: "requires port", 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 { for _, tt := range tests {

View File

@@ -385,13 +385,117 @@ func (x *ListFrontendsResponse) GetFrontendNames() []string {
return nil 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 { type FrontendInfo struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
Address string `protobuf:"bytes,2,opt,name=address,proto3" json:"address,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"` Protocol string `protobuf:"bytes,3,opt,name=protocol,proto3" json:"protocol,omitempty"`
Port uint32 `protobuf:"varint,4,opt,name=port,proto3" json:"port,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"` Description string `protobuf:"bytes,6,opt,name=description,proto3" json:"description,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
@@ -399,7 +503,7 @@ type FrontendInfo struct {
func (x *FrontendInfo) Reset() { func (x *FrontendInfo) Reset() {
*x = FrontendInfo{} *x = FrontendInfo{}
mi := &file_proto_maglev_proto_msgTypes[9] mi := &file_proto_maglev_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -411,7 +515,7 @@ func (x *FrontendInfo) String() string {
func (*FrontendInfo) ProtoMessage() {} func (*FrontendInfo) ProtoMessage() {}
func (x *FrontendInfo) ProtoReflect() protoreflect.Message { func (x *FrontendInfo) ProtoReflect() protoreflect.Message {
mi := &file_proto_maglev_proto_msgTypes[9] mi := &file_proto_maglev_proto_msgTypes[11]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -424,7 +528,7 @@ func (x *FrontendInfo) ProtoReflect() protoreflect.Message {
// Deprecated: Use FrontendInfo.ProtoReflect.Descriptor instead. // Deprecated: Use FrontendInfo.ProtoReflect.Descriptor instead.
func (*FrontendInfo) Descriptor() ([]byte, []int) { 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 { func (x *FrontendInfo) GetName() string {
@@ -455,9 +559,9 @@ func (x *FrontendInfo) GetPort() uint32 {
return 0 return 0
} }
func (x *FrontendInfo) GetBackendNames() []string { func (x *FrontendInfo) GetPools() []*PoolInfo {
if x != nil { if x != nil {
return x.BackendNames return x.Pools
} }
return nil return nil
} }
@@ -478,7 +582,7 @@ type ListBackendsResponse struct {
func (x *ListBackendsResponse) Reset() { func (x *ListBackendsResponse) Reset() {
*x = ListBackendsResponse{} *x = ListBackendsResponse{}
mi := &file_proto_maglev_proto_msgTypes[10] mi := &file_proto_maglev_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -490,7 +594,7 @@ func (x *ListBackendsResponse) String() string {
func (*ListBackendsResponse) ProtoMessage() {} func (*ListBackendsResponse) ProtoMessage() {}
func (x *ListBackendsResponse) ProtoReflect() protoreflect.Message { func (x *ListBackendsResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_maglev_proto_msgTypes[10] mi := &file_proto_maglev_proto_msgTypes[12]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -503,7 +607,7 @@ func (x *ListBackendsResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListBackendsResponse.ProtoReflect.Descriptor instead. // Deprecated: Use ListBackendsResponse.ProtoReflect.Descriptor instead.
func (*ListBackendsResponse) Descriptor() ([]byte, []int) { 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 { func (x *ListBackendsResponse) GetBackendNames() []string {
@@ -522,7 +626,7 @@ type ListHealthChecksResponse struct {
func (x *ListHealthChecksResponse) Reset() { func (x *ListHealthChecksResponse) Reset() {
*x = ListHealthChecksResponse{} *x = ListHealthChecksResponse{}
mi := &file_proto_maglev_proto_msgTypes[11] mi := &file_proto_maglev_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -534,7 +638,7 @@ func (x *ListHealthChecksResponse) String() string {
func (*ListHealthChecksResponse) ProtoMessage() {} func (*ListHealthChecksResponse) ProtoMessage() {}
func (x *ListHealthChecksResponse) ProtoReflect() protoreflect.Message { func (x *ListHealthChecksResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_maglev_proto_msgTypes[11] mi := &file_proto_maglev_proto_msgTypes[13]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -547,7 +651,7 @@ func (x *ListHealthChecksResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListHealthChecksResponse.ProtoReflect.Descriptor instead. // Deprecated: Use ListHealthChecksResponse.ProtoReflect.Descriptor instead.
func (*ListHealthChecksResponse) Descriptor() ([]byte, []int) { 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 { func (x *ListHealthChecksResponse) GetNames() []string {
@@ -572,7 +676,7 @@ type HTTPCheckParams struct {
func (x *HTTPCheckParams) Reset() { func (x *HTTPCheckParams) Reset() {
*x = HTTPCheckParams{} *x = HTTPCheckParams{}
mi := &file_proto_maglev_proto_msgTypes[12] mi := &file_proto_maglev_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -584,7 +688,7 @@ func (x *HTTPCheckParams) String() string {
func (*HTTPCheckParams) ProtoMessage() {} func (*HTTPCheckParams) ProtoMessage() {}
func (x *HTTPCheckParams) ProtoReflect() protoreflect.Message { func (x *HTTPCheckParams) ProtoReflect() protoreflect.Message {
mi := &file_proto_maglev_proto_msgTypes[12] mi := &file_proto_maglev_proto_msgTypes[14]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -597,7 +701,7 @@ func (x *HTTPCheckParams) ProtoReflect() protoreflect.Message {
// Deprecated: Use HTTPCheckParams.ProtoReflect.Descriptor instead. // Deprecated: Use HTTPCheckParams.ProtoReflect.Descriptor instead.
func (*HTTPCheckParams) Descriptor() ([]byte, []int) { 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 { func (x *HTTPCheckParams) GetPath() string {
@@ -660,7 +764,7 @@ type TCPCheckParams struct {
func (x *TCPCheckParams) Reset() { func (x *TCPCheckParams) Reset() {
*x = TCPCheckParams{} *x = TCPCheckParams{}
mi := &file_proto_maglev_proto_msgTypes[13] mi := &file_proto_maglev_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -672,7 +776,7 @@ func (x *TCPCheckParams) String() string {
func (*TCPCheckParams) ProtoMessage() {} func (*TCPCheckParams) ProtoMessage() {}
func (x *TCPCheckParams) ProtoReflect() protoreflect.Message { func (x *TCPCheckParams) ProtoReflect() protoreflect.Message {
mi := &file_proto_maglev_proto_msgTypes[13] mi := &file_proto_maglev_proto_msgTypes[15]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -685,7 +789,7 @@ func (x *TCPCheckParams) ProtoReflect() protoreflect.Message {
// Deprecated: Use TCPCheckParams.ProtoReflect.Descriptor instead. // Deprecated: Use TCPCheckParams.ProtoReflect.Descriptor instead.
func (*TCPCheckParams) Descriptor() ([]byte, []int) { 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 { func (x *TCPCheckParams) GetSsl() bool {
@@ -730,7 +834,7 @@ type HealthCheckInfo struct {
func (x *HealthCheckInfo) Reset() { func (x *HealthCheckInfo) Reset() {
*x = HealthCheckInfo{} *x = HealthCheckInfo{}
mi := &file_proto_maglev_proto_msgTypes[14] mi := &file_proto_maglev_proto_msgTypes[16]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -742,7 +846,7 @@ func (x *HealthCheckInfo) String() string {
func (*HealthCheckInfo) ProtoMessage() {} func (*HealthCheckInfo) ProtoMessage() {}
func (x *HealthCheckInfo) ProtoReflect() protoreflect.Message { func (x *HealthCheckInfo) ProtoReflect() protoreflect.Message {
mi := &file_proto_maglev_proto_msgTypes[14] mi := &file_proto_maglev_proto_msgTypes[16]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -755,7 +859,7 @@ func (x *HealthCheckInfo) ProtoReflect() protoreflect.Message {
// Deprecated: Use HealthCheckInfo.ProtoReflect.Descriptor instead. // Deprecated: Use HealthCheckInfo.ProtoReflect.Descriptor instead.
func (*HealthCheckInfo) Descriptor() ([]byte, []int) { 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 { func (x *HealthCheckInfo) GetName() string {
@@ -856,15 +960,14 @@ type BackendInfo struct {
State string `protobuf:"bytes,3,opt,name=state,proto3" json:"state,omitempty"` State string `protobuf:"bytes,3,opt,name=state,proto3" json:"state,omitempty"`
Transitions []*TransitionRecord `protobuf:"bytes,4,rep,name=transitions,proto3" json:"transitions,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"` 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,6,opt,name=healthcheck,proto3" json:"healthcheck,omitempty"`
Healthcheck string `protobuf:"bytes,7,opt,name=healthcheck,proto3" json:"healthcheck,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
func (x *BackendInfo) Reset() { func (x *BackendInfo) Reset() {
*x = BackendInfo{} *x = BackendInfo{}
mi := &file_proto_maglev_proto_msgTypes[15] mi := &file_proto_maglev_proto_msgTypes[17]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -876,7 +979,7 @@ func (x *BackendInfo) String() string {
func (*BackendInfo) ProtoMessage() {} func (*BackendInfo) ProtoMessage() {}
func (x *BackendInfo) ProtoReflect() protoreflect.Message { func (x *BackendInfo) ProtoReflect() protoreflect.Message {
mi := &file_proto_maglev_proto_msgTypes[15] mi := &file_proto_maglev_proto_msgTypes[17]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -889,7 +992,7 @@ func (x *BackendInfo) ProtoReflect() protoreflect.Message {
// Deprecated: Use BackendInfo.ProtoReflect.Descriptor instead. // Deprecated: Use BackendInfo.ProtoReflect.Descriptor instead.
func (*BackendInfo) Descriptor() ([]byte, []int) { 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 { func (x *BackendInfo) GetName() string {
@@ -927,13 +1030,6 @@ func (x *BackendInfo) GetEnabled() bool {
return false return false
} }
func (x *BackendInfo) GetWeight() int32 {
if x != nil {
return x.Weight
}
return 0
}
func (x *BackendInfo) GetHealthcheck() string { func (x *BackendInfo) GetHealthcheck() string {
if x != nil { if x != nil {
return x.Healthcheck return x.Healthcheck
@@ -952,7 +1048,7 @@ type TransitionRecord struct {
func (x *TransitionRecord) Reset() { func (x *TransitionRecord) Reset() {
*x = TransitionRecord{} *x = TransitionRecord{}
mi := &file_proto_maglev_proto_msgTypes[16] mi := &file_proto_maglev_proto_msgTypes[18]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -964,7 +1060,7 @@ func (x *TransitionRecord) String() string {
func (*TransitionRecord) ProtoMessage() {} func (*TransitionRecord) ProtoMessage() {}
func (x *TransitionRecord) ProtoReflect() protoreflect.Message { func (x *TransitionRecord) ProtoReflect() protoreflect.Message {
mi := &file_proto_maglev_proto_msgTypes[16] mi := &file_proto_maglev_proto_msgTypes[18]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -977,7 +1073,7 @@ func (x *TransitionRecord) ProtoReflect() protoreflect.Message {
// Deprecated: Use TransitionRecord.ProtoReflect.Descriptor instead. // Deprecated: Use TransitionRecord.ProtoReflect.Descriptor instead.
func (*TransitionRecord) Descriptor() ([]byte, []int) { 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 { func (x *TransitionRecord) GetFrom() string {
@@ -1011,7 +1107,7 @@ type BackendEvent struct {
func (x *BackendEvent) Reset() { func (x *BackendEvent) Reset() {
*x = BackendEvent{} *x = BackendEvent{}
mi := &file_proto_maglev_proto_msgTypes[17] mi := &file_proto_maglev_proto_msgTypes[19]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -1023,7 +1119,7 @@ func (x *BackendEvent) String() string {
func (*BackendEvent) ProtoMessage() {} func (*BackendEvent) ProtoMessage() {}
func (x *BackendEvent) ProtoReflect() protoreflect.Message { func (x *BackendEvent) ProtoReflect() protoreflect.Message {
mi := &file_proto_maglev_proto_msgTypes[17] mi := &file_proto_maglev_proto_msgTypes[19]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -1036,7 +1132,7 @@ func (x *BackendEvent) ProtoReflect() protoreflect.Message {
// Deprecated: Use BackendEvent.ProtoReflect.Descriptor instead. // Deprecated: Use BackendEvent.ProtoReflect.Descriptor instead.
func (*BackendEvent) Descriptor() ([]byte, []int) { 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 { func (x *BackendEvent) GetBackendName() string {
@@ -1071,13 +1167,19 @@ const file_proto_maglev_proto_rawDesc = "" +
"\x04name\x18\x01 \x01(\tR\x04name\"\x0e\n" + "\x04name\x18\x01 \x01(\tR\x04name\"\x0e\n" +
"\fWatchRequest\">\n" + "\fWatchRequest\">\n" +
"\x15ListFrontendsResponse\x12%\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" + "\fFrontendInfo\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" +
"\aaddress\x18\x02 \x01(\tR\aaddress\x12\x1a\n" + "\aaddress\x18\x02 \x01(\tR\aaddress\x12\x1a\n" +
"\bprotocol\x18\x03 \x01(\tR\bprotocol\x12\x12\n" + "\bprotocol\x18\x03 \x01(\tR\bprotocol\x12\x12\n" +
"\x04port\x18\x04 \x01(\rR\x04port\x12#\n" + "\x04port\x18\x04 \x01(\rR\x04port\x12&\n" +
"\rbackend_names\x18\x05 \x03(\tR\fbackendNames\x12 \n" + "\x05pools\x18\x05 \x03(\v2\x10.maglev.PoolInfoR\x05pools\x12 \n" +
"\vdescription\x18\x06 \x01(\tR\vdescription\";\n" + "\vdescription\x18\x06 \x01(\tR\vdescription\";\n" +
"\x14ListBackendsResponse\x12#\n" + "\x14ListBackendsResponse\x12#\n" +
"\rbackend_names\x18\x01 \x03(\tR\fbackendNames\"0\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" + " \x01(\x05R\x04rise\x12\x12\n" +
"\x04fall\x18\v \x01(\x05R\x04fall\x12+\n" + "\x04fall\x18\v \x01(\x05R\x04fall\x12+\n" +
"\x04http\x18\f \x01(\v2\x17.maglev.HTTPCheckParamsR\x04http\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" + "\vBackendInfo\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" +
"\aaddress\x18\x02 \x01(\tR\aaddress\x12\x14\n" + "\aaddress\x18\x02 \x01(\tR\aaddress\x12\x14\n" +
"\x05state\x18\x03 \x01(\tR\x05state\x12:\n" + "\x05state\x18\x03 \x01(\tR\x05state\x12:\n" +
"\vtransitions\x18\x04 \x03(\v2\x18.maglev.TransitionRecordR\vtransitions\x12\x18\n" + "\vtransitions\x18\x04 \x03(\v2\x18.maglev.TransitionRecordR\vtransitions\x12\x18\n" +
"\aenabled\x18\x05 \x01(\bR\aenabled\x12\x16\n" + "\aenabled\x18\x05 \x01(\bR\aenabled\x12 \n" +
"\x06weight\x18\x06 \x01(\x05R\x06weight\x12 \n" + "\vhealthcheck\x18\x06 \x01(\tR\vhealthcheck\"T\n" +
"\vhealthcheck\x18\a \x01(\tR\vhealthcheck\"T\n" +
"\x10TransitionRecord\x12\x12\n" + "\x10TransitionRecord\x12\x12\n" +
"\x04from\x18\x01 \x01(\tR\x04from\x12\x0e\n" + "\x04from\x18\x01 \x01(\tR\x04from\x12\x0e\n" +
"\x02to\x18\x02 \x01(\tR\x02to\x12\x1c\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 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{ var file_proto_maglev_proto_goTypes = []any{
(*ListFrontendsRequest)(nil), // 0: maglev.ListFrontendsRequest (*ListFrontendsRequest)(nil), // 0: maglev.ListFrontendsRequest
(*GetFrontendRequest)(nil), // 1: maglev.GetFrontendRequest (*GetFrontendRequest)(nil), // 1: maglev.GetFrontendRequest
@@ -1167,44 +1268,48 @@ var file_proto_maglev_proto_goTypes = []any{
(*GetHealthCheckRequest)(nil), // 6: maglev.GetHealthCheckRequest (*GetHealthCheckRequest)(nil), // 6: maglev.GetHealthCheckRequest
(*WatchRequest)(nil), // 7: maglev.WatchRequest (*WatchRequest)(nil), // 7: maglev.WatchRequest
(*ListFrontendsResponse)(nil), // 8: maglev.ListFrontendsResponse (*ListFrontendsResponse)(nil), // 8: maglev.ListFrontendsResponse
(*FrontendInfo)(nil), // 9: maglev.FrontendInfo (*PoolBackendInfo)(nil), // 9: maglev.PoolBackendInfo
(*ListBackendsResponse)(nil), // 10: maglev.ListBackendsResponse (*PoolInfo)(nil), // 10: maglev.PoolInfo
(*ListHealthChecksResponse)(nil), // 11: maglev.ListHealthChecksResponse (*FrontendInfo)(nil), // 11: maglev.FrontendInfo
(*HTTPCheckParams)(nil), // 12: maglev.HTTPCheckParams (*ListBackendsResponse)(nil), // 12: maglev.ListBackendsResponse
(*TCPCheckParams)(nil), // 13: maglev.TCPCheckParams (*ListHealthChecksResponse)(nil), // 13: maglev.ListHealthChecksResponse
(*HealthCheckInfo)(nil), // 14: maglev.HealthCheckInfo (*HTTPCheckParams)(nil), // 14: maglev.HTTPCheckParams
(*BackendInfo)(nil), // 15: maglev.BackendInfo (*TCPCheckParams)(nil), // 15: maglev.TCPCheckParams
(*TransitionRecord)(nil), // 16: maglev.TransitionRecord (*HealthCheckInfo)(nil), // 16: maglev.HealthCheckInfo
(*BackendEvent)(nil), // 17: maglev.BackendEvent (*BackendInfo)(nil), // 17: maglev.BackendInfo
(*TransitionRecord)(nil), // 18: maglev.TransitionRecord
(*BackendEvent)(nil), // 19: maglev.BackendEvent
} }
var file_proto_maglev_proto_depIdxs = []int32{ var file_proto_maglev_proto_depIdxs = []int32{
12, // 0: maglev.HealthCheckInfo.http:type_name -> maglev.HTTPCheckParams 9, // 0: maglev.PoolInfo.backends:type_name -> maglev.PoolBackendInfo
13, // 1: maglev.HealthCheckInfo.tcp:type_name -> maglev.TCPCheckParams 10, // 1: maglev.FrontendInfo.pools:type_name -> maglev.PoolInfo
16, // 2: maglev.BackendInfo.transitions:type_name -> maglev.TransitionRecord 14, // 2: maglev.HealthCheckInfo.http:type_name -> maglev.HTTPCheckParams
16, // 3: maglev.BackendEvent.transition:type_name -> maglev.TransitionRecord 15, // 3: maglev.HealthCheckInfo.tcp:type_name -> maglev.TCPCheckParams
0, // 4: maglev.Maglev.ListFrontends:input_type -> maglev.ListFrontendsRequest 18, // 4: maglev.BackendInfo.transitions:type_name -> maglev.TransitionRecord
1, // 5: maglev.Maglev.GetFrontend:input_type -> maglev.GetFrontendRequest 18, // 5: maglev.BackendEvent.transition:type_name -> maglev.TransitionRecord
2, // 6: maglev.Maglev.ListBackends:input_type -> maglev.ListBackendsRequest 0, // 6: maglev.Maglev.ListFrontends:input_type -> maglev.ListFrontendsRequest
3, // 7: maglev.Maglev.GetBackend:input_type -> maglev.GetBackendRequest 1, // 7: maglev.Maglev.GetFrontend:input_type -> maglev.GetFrontendRequest
4, // 8: maglev.Maglev.PauseBackend:input_type -> maglev.PauseResumeRequest 2, // 8: maglev.Maglev.ListBackends:input_type -> maglev.ListBackendsRequest
4, // 9: maglev.Maglev.ResumeBackend:input_type -> maglev.PauseResumeRequest 3, // 9: maglev.Maglev.GetBackend:input_type -> maglev.GetBackendRequest
5, // 10: maglev.Maglev.ListHealthChecks:input_type -> maglev.ListHealthChecksRequest 4, // 10: maglev.Maglev.PauseBackend:input_type -> maglev.PauseResumeRequest
6, // 11: maglev.Maglev.GetHealthCheck:input_type -> maglev.GetHealthCheckRequest 4, // 11: maglev.Maglev.ResumeBackend:input_type -> maglev.PauseResumeRequest
7, // 12: maglev.Maglev.WatchBackendEvents:input_type -> maglev.WatchRequest 5, // 12: maglev.Maglev.ListHealthChecks:input_type -> maglev.ListHealthChecksRequest
8, // 13: maglev.Maglev.ListFrontends:output_type -> maglev.ListFrontendsResponse 6, // 13: maglev.Maglev.GetHealthCheck:input_type -> maglev.GetHealthCheckRequest
9, // 14: maglev.Maglev.GetFrontend:output_type -> maglev.FrontendInfo 7, // 14: maglev.Maglev.WatchBackendEvents:input_type -> maglev.WatchRequest
10, // 15: maglev.Maglev.ListBackends:output_type -> maglev.ListBackendsResponse 8, // 15: maglev.Maglev.ListFrontends:output_type -> maglev.ListFrontendsResponse
15, // 16: maglev.Maglev.GetBackend:output_type -> maglev.BackendInfo 11, // 16: maglev.Maglev.GetFrontend:output_type -> maglev.FrontendInfo
15, // 17: maglev.Maglev.PauseBackend:output_type -> maglev.BackendInfo 12, // 17: maglev.Maglev.ListBackends:output_type -> maglev.ListBackendsResponse
15, // 18: maglev.Maglev.ResumeBackend:output_type -> maglev.BackendInfo 17, // 18: maglev.Maglev.GetBackend:output_type -> maglev.BackendInfo
11, // 19: maglev.Maglev.ListHealthChecks:output_type -> maglev.ListHealthChecksResponse 17, // 19: maglev.Maglev.PauseBackend:output_type -> maglev.BackendInfo
14, // 20: maglev.Maglev.GetHealthCheck:output_type -> maglev.HealthCheckInfo 17, // 20: maglev.Maglev.ResumeBackend:output_type -> maglev.BackendInfo
17, // 21: maglev.Maglev.WatchBackendEvents:output_type -> maglev.BackendEvent 13, // 21: maglev.Maglev.ListHealthChecks:output_type -> maglev.ListHealthChecksResponse
13, // [13:22] is the sub-list for method output_type 16, // 22: maglev.Maglev.GetHealthCheck:output_type -> maglev.HealthCheckInfo
4, // [4:13] is the sub-list for method input_type 19, // 23: maglev.Maglev.WatchBackendEvents:output_type -> maglev.BackendEvent
4, // [4:4] is the sub-list for extension type_name 15, // [15:24] is the sub-list for method output_type
4, // [4:4] is the sub-list for extension extendee 6, // [6:15] is the sub-list for method input_type
0, // [0:4] is the sub-list for field type_name 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() } func init() { file_proto_maglev_proto_init() }
@@ -1218,7 +1323,7 @@ func file_proto_maglev_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_maglev_proto_rawDesc), len(file_proto_maglev_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_maglev_proto_rawDesc), len(file_proto_maglev_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 18, NumMessages: 20,
NumExtensions: 0, NumExtensions: 0,
NumServices: 1, NumServices: 1,
}, },

View File

@@ -137,13 +137,24 @@ func (s *Server) WatchBackendEvents(_ *WatchRequest, stream Maglev_WatchBackendE
// ---- conversion helpers ---------------------------------------------------- // ---- conversion helpers ----------------------------------------------------
func frontendToProto(name string, fe config.Frontend) *FrontendInfo { 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{ return &FrontendInfo{
Name: name, Name: name,
Address: fe.Address.String(), Address: fe.Address.String(),
Protocol: fe.Protocol, Protocol: fe.Protocol,
Port: uint32(fe.Port), Port: uint32(fe.Port),
Description: fe.Description, Description: fe.Description,
BackendNames: fe.Backends, Pools: pools,
} }
} }
@@ -153,7 +164,6 @@ func backendToProto(snap checker.BackendSnapshot) *BackendInfo {
Address: snap.Health.Address.String(), Address: snap.Health.Address.String(),
State: snap.Health.State.String(), State: snap.Health.State.String(),
Enabled: snap.Config.Enabled, Enabled: snap.Config.Enabled,
Weight: int32(snap.Config.Weight),
Healthcheck: snap.Config.HealthCheck, Healthcheck: snap.Config.HealthCheck,
} }
for _, t := range snap.Health.Transitions { for _, t := range snap.Health.Transitions {

View File

@@ -33,7 +33,6 @@ func makeTestChecker(ctx context.Context) *checker.Checker {
Address: net.ParseIP("10.0.0.2"), Address: net.ParseIP("10.0.0.2"),
HealthCheck: "icmp", HealthCheck: "icmp",
Enabled: true, Enabled: true,
Weight: 100,
}, },
}, },
Frontends: map[string]config.Frontend{ Frontends: map[string]config.Frontend{
@@ -41,7 +40,11 @@ func makeTestChecker(ctx context.Context) *checker.Checker {
Address: net.ParseIP("192.0.2.1"), Address: net.ParseIP("192.0.2.1"),
Protocol: "tcp", Protocol: "tcp",
Port: 80, 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 { if info.Port != 80 {
t.Errorf("GetFrontend port: got %d, want 80", info.Port) t.Errorf("GetFrontend port: got %d, want 80", info.Port)
} }
if len(info.BackendNames) != 1 || info.BackendNames[0] != "be0" { if len(info.Pools) != 1 || info.Pools[0].Name != "primary" {
t.Errorf("GetFrontend backend_names: got %v, want [be0]", info.BackendNames) 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 { if !info.Enabled {
t.Error("expected enabled=true") t.Error("expected enabled=true")
} }
if info.Weight != 100 {
t.Errorf("weight: got %d, want 100", info.Weight)
}
if info.Healthcheck != "icmp" { if info.Healthcheck != "icmp" {
t.Errorf("healthcheck: got %q, want icmp", info.Healthcheck) t.Errorf("healthcheck: got %q, want icmp", info.Healthcheck)
} }

View File

@@ -40,11 +40,11 @@ func doHTTPProbe(ctx context.Context, cfg ProbeConfig, useTLS bool) health.Probe
} }
} }
scheme := "http" // Always use "http" scheme: TLS (if any) is already applied to conn during
if useTLS { // the netns dial phase. Using "https" here would cause http.Transport to
scheme = "https" // wrap conn in a second TLS layer, producing "http: server gave HTTP
} // response to HTTPS client".
target := fmt.Sprintf("%s://%s%s", scheme, net.JoinHostPort(cfg.Target.String(), strconv.Itoa(int(port))), p.Path) target := fmt.Sprintf("http://%s%s", net.JoinHostPort(cfg.Target.String(), strconv.Itoa(int(port))), p.Path)
hostHeader := p.Host hostHeader := p.Host
if hostHeader == "" { if hostHeader == "" {

View File

@@ -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) { func TestHTTPProbeNoRedirect(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/other", http.StatusFound) http.Redirect(w, r, "/other", http.StatusFound)

View File

@@ -49,12 +49,22 @@ message ListFrontendsResponse {
repeated string frontend_names = 1; repeated string frontend_names = 1;
} }
message PoolBackendInfo {
string name = 1;
int32 weight = 2;
}
message PoolInfo {
string name = 1;
repeated PoolBackendInfo backends = 2;
}
message FrontendInfo { message FrontendInfo {
string name = 1; string name = 1;
string address = 2; string address = 2;
string protocol = 3; string protocol = 3;
uint32 port = 4; uint32 port = 4;
repeated string backend_names = 5; repeated PoolInfo pools = 5;
string description = 6; string description = 6;
} }
@@ -104,8 +114,7 @@ message BackendInfo {
string state = 3; string state = 3;
repeated TransitionRecord transitions = 4; repeated TransitionRecord transitions = 4;
bool enabled = 5; bool enabled = 5;
int32 weight = 6; string healthcheck = 6;
string healthcheck = 7;
} }
message TransitionRecord { message TransitionRecord {