Files
vpp-maglev/internal/vpp/lbsync_test.go

645 lines
20 KiB
Go

// SPDX-License-Identifier: Apache-2.0
package vpp
import (
"net"
"testing"
"git.ipng.ch/ipng/vpp-maglev/internal/config"
"git.ipng.ch/ipng/vpp-maglev/internal/health"
)
// TestParseLBVIPSnapshot pins the parser for `show lb vips verbose` output.
// The text below is a synthetic sample that mirrors format_lb_vip_detailed
// in src/plugins/lb/lb.c: a header line per VIP optionally carrying the
// src_ip_sticky token, followed by a protocol:/port: sub-line for non all-
// port VIPs. If VPP changes this format the test will fail loudly — the
// scrape is a temporary workaround until lb_vip_v2_dump exists.
func TestParseLBVIPSnapshot(t *testing.T) {
text := ` ip4-gre4 [1] 192.0.2.1/32 src_ip_sticky
new_size:1024
protocol:6 port:80
counters:
ip4-gre4 [2] 192.0.2.2/32
new_size:1024
protocol:17 port:53
ip6-gre6 [3] 2001:db8::1/128 src_ip_sticky
new_size:1024
protocol:6 port:443
ip4-gre4 [4] 192.0.2.3/32
new_size:1024
`
got := parseLBVIPSnapshot(text)
want := map[vipKey]lbVIPSnapshot{
{prefix: "192.0.2.1/32", protocol: 6, port: 80}: {index: 1, sticky: true},
{prefix: "192.0.2.2/32", protocol: 17, port: 53}: {index: 2, sticky: false},
{prefix: "2001:db8::1/128", protocol: 6, port: 443}: {index: 3, sticky: true},
{prefix: "192.0.2.3/32", protocol: 255, port: 0}: {index: 4, sticky: false}, // all-port VIP
}
if len(got) != len(want) {
t.Errorf("got %d entries, want %d: %#v", len(got), len(want), got)
}
for k, v := range want {
g, ok := got[k]
if !ok {
t.Errorf("missing key %+v", k)
continue
}
if g != v {
t.Errorf("key %+v: got %+v, want %+v", k, g, v)
}
}
}
// fakeStateSource implements StateSource from a static map.
type fakeStateSource struct {
cfg *config.Config
states map[string]health.State
}
func (f *fakeStateSource) Config() *config.Config { return f.cfg }
func (f *fakeStateSource) BackendState(name string) (health.State, bool) {
s, ok := f.states[name]
return s, ok
}
// TestDesiredFromFrontendFailover is the end-to-end integration test for
// priority-failover in the VPP sync path: given a frontend with two pools,
// the desired weights flip between pools based on which has any up backends.
// This exercises vpp.desiredFromFrontend which wraps the pure helpers in
// the health package; those helpers are unit-tested separately in health.
func TestDesiredFromFrontendFailover(t *testing.T) {
ip := func(s string) net.IP { return net.ParseIP(s).To4() }
cfg := &config.Config{
Backends: map[string]config.Backend{
"p1": {Address: ip("10.0.0.1"), Enabled: true},
"p2": {Address: ip("10.0.0.2"), Enabled: true},
"s1": {Address: ip("10.0.0.11"), Enabled: true},
"s2": {Address: ip("10.0.0.12"), Enabled: true},
},
}
fe := config.Frontend{
Address: ip("192.0.2.1"),
Protocol: "tcp",
Port: 80,
Pools: []config.Pool{
{Name: "primary", Backends: map[string]config.PoolBackend{
"p1": {Weight: 100},
"p2": {Weight: 100},
}},
{Name: "fallback", Backends: map[string]config.PoolBackend{
"s1": {Weight: 100},
"s2": {Weight: 100},
}},
},
}
tests := []struct {
name string
states map[string]health.State
want map[string]uint8 // backend IP → expected weight
}{
{
name: "primary all up → primary serves, secondary standby",
states: map[string]health.State{
"p1": health.StateUp, "p2": health.StateUp,
"s1": health.StateUp, "s2": health.StateUp,
},
want: map[string]uint8{
"10.0.0.1": 100, "10.0.0.2": 100,
"10.0.0.11": 0, "10.0.0.12": 0,
},
},
{
name: "primary 1 up → primary still serves",
states: map[string]health.State{
"p1": health.StateDown, "p2": health.StateUp,
"s1": health.StateUp, "s2": health.StateUp,
},
want: map[string]uint8{
"10.0.0.1": 0, "10.0.0.2": 100,
"10.0.0.11": 0, "10.0.0.12": 0,
},
},
{
name: "primary all down → failover to secondary",
states: map[string]health.State{
"p1": health.StateDown, "p2": health.StateDown,
"s1": health.StateUp, "s2": health.StateUp,
},
want: map[string]uint8{
"10.0.0.1": 0, "10.0.0.2": 0,
"10.0.0.11": 100, "10.0.0.12": 100,
},
},
{
name: "primary all disabled → failover",
states: map[string]health.State{
"p1": health.StateDisabled, "p2": health.StateDisabled,
"s1": health.StateUp, "s2": health.StateUp,
},
want: map[string]uint8{
"10.0.0.1": 0, "10.0.0.2": 0,
"10.0.0.11": 100, "10.0.0.12": 100,
},
},
{
name: "everything down → all zero, no serving",
states: map[string]health.State{
"p1": health.StateDown, "p2": health.StateDown,
"s1": health.StateDown, "s2": health.StateDown,
},
want: map[string]uint8{
"10.0.0.1": 0, "10.0.0.2": 0,
"10.0.0.11": 0, "10.0.0.12": 0,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
src := &fakeStateSource{cfg: cfg, states: tc.states}
d := desiredFromFrontend(cfg, fe, src)
for addr, wantW := range tc.want {
got, ok := d.ASes[addr]
if !ok {
t.Errorf("%s: missing from desired set", addr)
continue
}
if got.Weight != wantW {
t.Errorf("%s: weight got %d, want %d", addr, got.Weight, wantW)
}
}
if len(d.ASes) != len(tc.want) {
t.Errorf("got %d ASes, want %d", len(d.ASes), len(tc.want))
}
})
}
}
// TestDesiredFromFrontendSharedBackend exercises the exact shape of
// maglev.yaml: two frontends that share three backends across primary
// and fallback pools with different per-pool weights. The key
// invariants being pinned:
//
// - Each frontend's desiredFromFrontend must read its own
// per-pool-membership weights, never leaking weights from a sibling
// frontend's pool config.
// - When the primary pool has at least one backend up, the fallback
// pool's backends must all be weight=0 (standby).
// - When every primary-pool backend is non-up (down / paused /
// disabled), failover kicks in: the fallback pool's backends get
// their configured weights, and primary-pool backends stay at 0.
//
// Frontends modelled below:
//
// nginx-ip4-http:
// primary: nginx0-frggh0 w=10, nginx0-nlams0 w=100
// fallback: nginx0-chlzn0 w=100
//
// nginx-ip6-https:
// primary: nginx0-frggh0 w=100
// fallback: nginx0-nlams0 w=100, nginx0-chlzn0 w=100
//
// Note that nginx0-frggh0 is configured with weight 10 in the ip4
// primary but 100 in the ip6 primary — this is the exact crossed
// configuration that the user reported as producing weight=10 in the
// ip6 VIP (a regression).
func TestDesiredFromFrontendSharedBackend(t *testing.T) {
ip := func(s string) net.IP { return net.ParseIP(s).To4() }
frggh := "198.19.6.76"
nlams := "198.19.4.118"
chlzn := "198.19.6.167"
cfg := &config.Config{
Backends: map[string]config.Backend{
"nginx0-frggh0": {Address: ip(frggh), Enabled: true},
"nginx0-nlams0": {Address: ip(nlams), Enabled: true},
"nginx0-chlzn0": {Address: ip(chlzn), Enabled: true},
},
}
feIP4 := config.Frontend{
Address: ip("198.19.0.254"),
Protocol: "tcp",
Port: 80,
Pools: []config.Pool{
{Name: "primary", Backends: map[string]config.PoolBackend{
"nginx0-frggh0": {Weight: 10},
"nginx0-nlams0": {Weight: 100},
}},
{Name: "fallback", Backends: map[string]config.PoolBackend{
"nginx0-chlzn0": {Weight: 100},
}},
},
}
feIP6 := config.Frontend{
Address: net.ParseIP("2001:db8::1"),
Protocol: "tcp",
Port: 443,
Pools: []config.Pool{
{Name: "primary", Backends: map[string]config.PoolBackend{
"nginx0-frggh0": {Weight: 100},
}},
{Name: "fallback", Backends: map[string]config.PoolBackend{
"nginx0-nlams0": {Weight: 100},
"nginx0-chlzn0": {Weight: 100},
}},
},
}
type want struct {
ip4 map[string]uint8
ip6 map[string]uint8
}
tests := []struct {
name string
states map[string]health.State
want want
}{
{
name: "all up — each primary serves with its own weights",
states: map[string]health.State{
"nginx0-frggh0": health.StateUp,
"nginx0-nlams0": health.StateUp,
"nginx0-chlzn0": health.StateUp,
},
want: want{
ip4: map[string]uint8{frggh: 10, nlams: 100, chlzn: 0},
ip6: map[string]uint8{frggh: 100, nlams: 0, chlzn: 0},
},
},
{
name: "frggh0 disabled — ip4 primary still served by nlams0, ip6 fails over to fallback",
states: map[string]health.State{
"nginx0-frggh0": health.StateDisabled,
"nginx0-nlams0": health.StateUp,
"nginx0-chlzn0": health.StateUp,
},
want: want{
// ip4 primary still has nlams0 up, so stays on primary;
// frggh0 is in primary but disabled → 0.
ip4: map[string]uint8{frggh: 0, nlams: 100, chlzn: 0},
// ip6 primary has only frggh0 (disabled) → fallback
// pool activates and both of its backends get their
// configured weights.
ip6: map[string]uint8{frggh: 0, nlams: 100, chlzn: 100},
},
},
{
name: "frggh0 paused — same failover shape as disabled for ip6",
states: map[string]health.State{
"nginx0-frggh0": health.StatePaused,
"nginx0-nlams0": health.StateUp,
"nginx0-chlzn0": health.StateUp,
},
want: want{
ip4: map[string]uint8{frggh: 0, nlams: 100, chlzn: 0},
ip6: map[string]uint8{frggh: 0, nlams: 100, chlzn: 100},
},
},
{
name: "frggh0 down — same failover shape as disabled for ip6",
states: map[string]health.State{
"nginx0-frggh0": health.StateDown,
"nginx0-nlams0": health.StateUp,
"nginx0-chlzn0": health.StateUp,
},
want: want{
ip4: map[string]uint8{frggh: 0, nlams: 100, chlzn: 0},
ip6: map[string]uint8{frggh: 0, nlams: 100, chlzn: 100},
},
},
{
name: "ip4 primary all down → failover to chlzn0; ip6 unaffected",
states: map[string]health.State{
"nginx0-frggh0": health.StateDown,
"nginx0-nlams0": health.StateDown,
"nginx0-chlzn0": health.StateUp,
},
want: want{
// ip4 primary has nothing up → fallback activates.
ip4: map[string]uint8{frggh: 0, nlams: 0, chlzn: 100},
// ip6 primary has frggh0 (down) → fallback activates
// too; nlams0 is in ip6 fallback but down, chlzn0 is
// up and carries traffic.
ip6: map[string]uint8{frggh: 0, nlams: 0, chlzn: 100},
},
},
{
name: "all backends down → everyone zero",
states: map[string]health.State{
"nginx0-frggh0": health.StateDown,
"nginx0-nlams0": health.StateDown,
"nginx0-chlzn0": health.StateDown,
},
want: want{
ip4: map[string]uint8{frggh: 0, nlams: 0, chlzn: 0},
ip6: map[string]uint8{frggh: 0, nlams: 0, chlzn: 0},
},
},
{
name: "all backends disabled → everyone zero (and flushed)",
states: map[string]health.State{
"nginx0-frggh0": health.StateDisabled,
"nginx0-nlams0": health.StateDisabled,
"nginx0-chlzn0": health.StateDisabled,
},
want: want{
ip4: map[string]uint8{frggh: 0, nlams: 0, chlzn: 0},
ip6: map[string]uint8{frggh: 0, nlams: 0, chlzn: 0},
},
},
{
name: "frggh0 re-enabled and up — each frontend returns to its own configured weight (regression)",
states: map[string]health.State{
"nginx0-frggh0": health.StateUp,
"nginx0-nlams0": health.StateUp,
"nginx0-chlzn0": health.StateUp,
},
want: want{
// This is the specific regression the user reported:
// after a disable/enable cycle, the ip6 VIP should
// return to weight=100 for frggh0 (its own pool's
// configured weight), not 10 (ip4's weight).
ip4: map[string]uint8{frggh: 10, nlams: 100, chlzn: 0},
ip6: map[string]uint8{frggh: 100, nlams: 0, chlzn: 0},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
src := &fakeStateSource{cfg: cfg, states: tc.states}
d4 := desiredFromFrontend(cfg, feIP4, src)
for addr, w := range tc.want.ip4 {
got, ok := d4.ASes[addr]
if !ok {
t.Errorf("ip4: %s missing from desired set", addr)
continue
}
if got.Weight != w {
t.Errorf("ip4: %s weight got %d, want %d", addr, got.Weight, w)
}
}
if len(d4.ASes) != len(tc.want.ip4) {
t.Errorf("ip4: got %d ASes, want %d", len(d4.ASes), len(tc.want.ip4))
}
d6 := desiredFromFrontend(cfg, feIP6, src)
for addr, w := range tc.want.ip6 {
got, ok := d6.ASes[addr]
if !ok {
t.Errorf("ip6: %s missing from desired set", addr)
continue
}
if got.Weight != w {
t.Errorf("ip6: %s weight got %d, want %d", addr, got.Weight, w)
}
}
if len(d6.ASes) != len(tc.want.ip6) {
t.Errorf("ip6: got %d ASes, want %d", len(d6.ASes), len(tc.want.ip6))
}
// Also exercise desiredFromConfig (the batch version used
// by the 30-second periodic SyncLBStateAll): it iterates
// every frontend in cfg and must produce the same
// per-frontend weights as desiredFromFrontend called
// directly. A bug where one frontend's pool config leaks
// into another would show up here too.
cfgBatch := &config.Config{
Backends: cfg.Backends,
Frontends: map[string]config.Frontend{
"nginx-ip4-http": feIP4,
"nginx-ip6-https": feIP6,
},
}
batch := desiredFromConfig(cfgBatch, src)
byAddr := map[string]desiredVIP{}
for _, d := range batch {
byAddr[d.Prefix.IP.String()] = d
}
if d := byAddr["198.19.0.254"]; true {
for addr, w := range tc.want.ip4 {
if got := d.ASes[addr].Weight; got != w {
t.Errorf("batch ip4: %s weight got %d, want %d", addr, got, w)
}
}
}
if d := byAddr["2001:db8::1"]; true {
for addr, w := range tc.want.ip6 {
if got := d.ASes[addr].Weight; got != w {
t.Errorf("batch ip6: %s weight got %d, want %d", addr, got, w)
}
}
}
})
}
}
// TestSortedIPKeysDeterministic pins the iteration-order helper that
// reconcileVIP and recreateVIP use to sequence their lb_as_add_del
// calls. The Maglev lookup table in VPP's LB plugin breaks per-bucket
// ties by the order ASes sit in its internal vec, which is just the
// order maglevd issued add calls — so if this helper ever stops
// returning a total, stable ordering, two independent maglevd
// instances on the same config can silently program different
// new-flow tables.
//
// Sort order is numeric (by the parsed net.IP), not lexicographic.
// The specific cases that a string sort would get wrong and a
// numeric sort must get right:
//
// - 10.0.0.2 < 10.0.0.10 (string sort puts "10" before "2")
// - 2001:db8::2 < 2001:db8::10 (same issue in v6)
// - all IPv4 before all IPv6 (operator-friendly grouping)
func TestSortedIPKeysDeterministic(t *testing.T) {
t.Run("empty", func(t *testing.T) {
got := sortedIPKeys(map[string]int{})
if len(got) != 0 {
t.Errorf("empty map: got %v, want []", got)
}
})
t.Run("single entry", func(t *testing.T) {
got := sortedIPKeys(map[string]int{"10.0.0.1": 1})
if len(got) != 1 || got[0] != "10.0.0.1" {
t.Errorf("got %v, want [10.0.0.1]", got)
}
})
t.Run("v4 numeric order beats string order", func(t *testing.T) {
// The headline bug: "10.0.0.10" < "10.0.0.2" lexicographically
// because '1' < '2'. Numeric sort must place 2 before 10.
m := map[string]int{
"10.0.0.10": 1,
"10.0.0.2": 2,
"10.0.0.1": 3,
"10.0.0.11": 4,
}
got := sortedIPKeys(m)
want := []string{"10.0.0.1", "10.0.0.2", "10.0.0.10", "10.0.0.11"}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("pos %d: got %q, want %q", i, got[i], want[i])
}
}
})
t.Run("v6 numeric order beats string order", func(t *testing.T) {
// Same bug in v6: "2001:db8::10" < "2001:db8::2" lexicographically.
// The To16() canonical byte form handles both compressed and
// expanded forms correctly.
m := map[string]int{
"2001:db8::10": 1,
"2001:db8::2": 2,
"2001:db8::1": 3,
}
got := sortedIPKeys(m)
want := []string{"2001:db8::1", "2001:db8::2", "2001:db8::10"}
for i := range want {
if got[i] != want[i] {
t.Errorf("pos %d: got %q, want %q", i, got[i], want[i])
}
}
})
t.Run("v4 before v6", func(t *testing.T) {
// Mixed-family frontends: the operator-friendly order is
// the v4 block before the v6 block, each sorted numerically
// within its family.
m := map[string]int{
"2001:db8::1": 1,
"10.0.0.2": 2,
"10.0.0.1": 3,
"fe80::1": 4,
"192.168.0.1": 5,
}
got := sortedIPKeys(m)
want := []string{
"10.0.0.1", "10.0.0.2", "192.168.0.1",
"2001:db8::1", "fe80::1",
}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("pos %d: got %q, want %q", i, got[i], want[i])
}
}
})
t.Run("repeated calls produce identical sequence", func(t *testing.T) {
// Core determinism property: Go's map iteration is randomised,
// but sortedIPKeys must normalise it. Run the helper many
// times and compare every result to the first — if the
// normalisation ever breaks we'll see a divergence well within
// the loop count.
m := map[string]int{
"10.0.0.5": 1, "10.0.0.3": 2, "10.0.0.11": 3,
"10.0.0.2": 4, "10.0.0.4": 5, "10.0.0.20": 6,
}
first := sortedIPKeys(m)
for i := 0; i < 1000; i++ {
got := sortedIPKeys(m)
if len(got) != len(first) {
t.Fatalf("iter %d: length drift: got %v, first %v", i, got, first)
}
for j := range first {
if got[j] != first[j] {
t.Fatalf("iter %d pos %d: got %q, first %q", i, j, got[j], first[j])
}
}
}
})
t.Run("insertion order does not matter", func(t *testing.T) {
// A map built by inserting keys in ascending order must
// produce the same result as one built in descending order.
// Both go through the same normalisation.
asc := map[string]int{}
for _, k := range []string{"10.0.0.1", "10.0.0.2", "10.0.0.3", "10.0.0.10", "10.0.0.11"} {
asc[k] = 0
}
desc := map[string]int{}
for _, k := range []string{"10.0.0.11", "10.0.0.10", "10.0.0.3", "10.0.0.2", "10.0.0.1"} {
desc[k] = 0
}
gotAsc := sortedIPKeys(asc)
gotDesc := sortedIPKeys(desc)
if len(gotAsc) != len(gotDesc) {
t.Fatalf("length mismatch: asc %v, desc %v", gotAsc, gotDesc)
}
for i := range gotAsc {
if gotAsc[i] != gotDesc[i] {
t.Errorf("pos %d: asc %q, desc %q", i, gotAsc[i], gotDesc[i])
}
}
})
t.Run("desiredAS map", func(t *testing.T) {
// Exercise the actual call-site type: map[string]desiredAS.
// If the generic helper ever loses its type parameterisation
// this catches it at compile time (the call would fail).
m := map[string]desiredAS{
"10.0.0.9": {Address: net.ParseIP("10.0.0.9"), Weight: 100},
"10.0.0.11": {Address: net.ParseIP("10.0.0.11"), Weight: 100},
"10.0.0.5": {Address: net.ParseIP("10.0.0.5"), Weight: 50},
"10.0.0.1": {Address: net.ParseIP("10.0.0.1"), Weight: 25},
}
got := sortedIPKeys(m)
want := []string{"10.0.0.1", "10.0.0.5", "10.0.0.9", "10.0.0.11"}
for i := range want {
if got[i] != want[i] {
t.Errorf("pos %d: got %q, want %q", i, got[i], want[i])
}
}
})
}
// TestCompareIPNumeric pins the ordering comparator that sortedIPKeys
// delegates to. Split out so the v4/v6 boundary and nil-safety logic
// have named failure modes rather than being buried in the map-based
// subtests.
func TestCompareIPNumeric(t *testing.T) {
cases := []struct {
name string
a, b net.IP
want int // -1, 0, +1 (sign of compareIPNumeric)
}{
{"v4 numeric asc", net.ParseIP("10.0.0.2"), net.ParseIP("10.0.0.10"), -1},
{"v4 numeric desc", net.ParseIP("10.0.0.10"), net.ParseIP("10.0.0.2"), 1},
{"v4 equal", net.ParseIP("10.0.0.1"), net.ParseIP("10.0.0.1"), 0},
{"v6 numeric asc", net.ParseIP("2001:db8::2"), net.ParseIP("2001:db8::10"), -1},
{"v6 numeric desc", net.ParseIP("2001:db8::10"), net.ParseIP("2001:db8::2"), 1},
{"v4 before v6", net.ParseIP("192.168.0.1"), net.ParseIP("2001:db8::1"), -1},
{"v6 after v4", net.ParseIP("2001:db8::1"), net.ParseIP("192.168.0.1"), 1},
{"nil before v4", nil, net.ParseIP("10.0.0.1"), -1},
{"v4 after nil", net.ParseIP("10.0.0.1"), nil, 1},
{"nil equal nil", nil, nil, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := compareIPNumeric(tc.a, tc.b)
sign := func(x int) int {
switch {
case x < 0:
return -1
case x > 0:
return 1
}
return 0
}
if sign(got) != tc.want {
t.Errorf("got %d (sign %d), want sign %d", got, sign(got), tc.want)
}
})
}
}