// Copyright (c) 2026, Pim van Pelt package checker import ( "context" "net" "testing" "time" "git.ipng.ch/ipng/vpp-maglev/internal/config" "git.ipng.ch/ipng/vpp-maglev/internal/health" ) func makeTestConfig(interval time.Duration, fall, rise int) *config.Config { return &config.Config{ HealthChecker: config.HealthCheckerConfig{TransitionHistory: 5}, HealthChecks: map[string]config.HealthCheck{ "icmp": { Type: "icmp", Interval: interval, Timeout: time.Second, Fall: fall, Rise: rise, }, }, Backends: map[string]config.Backend{ "be0": { Address: net.ParseIP("10.0.0.2"), HealthCheck: "icmp", Enabled: true, }, }, Frontends: map[string]config.Frontend{ "web": { Address: net.ParseIP("192.0.2.1"), Protocol: "tcp", Port: 80, Pools: []config.Pool{ {Name: "primary", Backends: map[string]config.PoolBackend{ "be0": {Weight: 100}, }}, }, }, }, } } func TestHealthCheckEqual(t *testing.T) { a := config.HealthCheck{ Type: "http", Interval: time.Second, Timeout: 2 * time.Second, Fall: 3, Rise: 2, HTTP: &config.HTTPParams{Path: "/healthz", ResponseCodeMin: 200, ResponseCodeMax: 200}, } b := a if !healthCheckEqual(a, b) { t.Error("identical configs should be equal") } b.Fall = 5 if healthCheckEqual(a, b) { t.Error("different Fall should not be equal") } b = a b.FastInterval = 500 * time.Millisecond if healthCheckEqual(a, b) { t.Error("different FastInterval should not be equal") } b = a b.HTTP = &config.HTTPParams{Path: "/other", ResponseCodeMin: 200, ResponseCodeMax: 200} if healthCheckEqual(a, b) { t.Error("different HTTP.Path should not be equal") } } func TestStateMachineViaBackend(t *testing.T) { b := health.New("be0", net.ParseIP("10.0.0.2"), 2, 3) pass := health.ProbeResult{OK: true, Layer: health.LayerL7, Code: "L7OK"} fail := health.ProbeResult{OK: false, Layer: health.LayerL4, Code: "L4CON"} if !b.Record(fail, 5) { t.Error("first fail from Unknown should transition to Down") } if b.State != health.StateDown { t.Errorf("expected down, got %s", b.State) } if b.Record(pass, 5) { t.Error("should not transition after 1 pass (rise=2)") } if !b.Record(pass, 5) { t.Error("should transition to Up after 2 passes") } if b.State != health.StateUp { t.Errorf("expected up, got %s", b.State) } } func TestStaggerDelay(t *testing.T) { interval := 10 * time.Second if got := staggerDelay(interval, 0, 10); got != 0 { t.Errorf("pos=0: got %v, want 0", got) } if got := staggerDelay(interval, 5, 10); got != 5*time.Second { t.Errorf("pos=5/10: got %v, want 5s", got) } if got := staggerDelay(interval, 0, 1); got != 0 { t.Errorf("total=1: got %v, want 0", got) } } func TestReloadAddsBackend(t *testing.T) { cfg := makeTestConfig(10*time.Millisecond, 3, 2) c := New(cfg) newCfg := makeTestConfig(10*time.Millisecond, 3, 2) newCfg.Backends["be1"] = config.Backend{ Address: net.ParseIP("10.0.0.3"), HealthCheck: "icmp", Enabled: true, } newCfg.Frontends["web2"] = config.Frontend{ Address: net.ParseIP("192.0.2.2"), Protocol: "tcp", Port: 443, Pools: []config.Pool{ {Name: "primary", Backends: map[string]config.PoolBackend{ "be1": {Weight: 100}, }}, }, } ctx, cancel := context.WithCancel(context.Background()) cancel() if err := c.Reload(ctx, newCfg); err != nil { t.Fatalf("Reload: %v", err) } c.mu.RLock() _, ok := c.workers["be1"] c.mu.RUnlock() if !ok { t.Error("new backend not added after Reload") } } func TestReloadRemovesBackend(t *testing.T) { cfg := makeTestConfig(10*time.Millisecond, 3, 2) c := New(cfg) ctx, cancel := context.WithCancel(context.Background()) cancel() // Seed a worker manually. c.mu.Lock() wCtx, wCancel := context.WithCancel(context.Background()) c.workers["be0"] = &worker{ backend: health.New("be0", net.ParseIP("10.0.0.2"), 2, 3), hc: cfg.HealthChecks["icmp"], entry: cfg.Backends["be0"], cancel: wCancel, } c.mu.Unlock() _ = wCtx // Remove all frontends → be0 is no longer active. newCfg := &config.Config{ HealthChecker: cfg.HealthChecker, HealthChecks: cfg.HealthChecks, Backends: cfg.Backends, Frontends: map[string]config.Frontend{}, } if err := c.Reload(ctx, newCfg); err != nil { t.Fatalf("Reload: %v", err) } c.mu.RLock() _, ok := c.workers["be0"] c.mu.RUnlock() if ok { t.Error("removed backend still present after Reload") } } func TestSharedBackendProbedOnce(t *testing.T) { // be0 is referenced by two frontends — only one worker should exist. cfg := makeTestConfig(10*time.Millisecond, 3, 2) cfg.Frontends["web-tls"] = config.Frontend{ Address: net.ParseIP("192.0.2.3"), Protocol: "tcp", Port: 443, Pools: []config.Pool{ {Name: "primary", Backends: map[string]config.PoolBackend{ "be0": {Weight: 100}, }}, }, } c := New(cfg) names := activeBackendNames(c.cfg) if len(names) != 1 || names[0] != "be0" { t.Errorf("expected exactly one active backend, got %v", names) } } func TestSubscribe(t *testing.T) { cfg := makeTestConfig(10*time.Millisecond, 1, 1) c := New(cfg) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go c.fanOut(ctx) ch, unsub := c.Subscribe() defer unsub() e := Event{ FrontendName: "web", BackendName: "be0", Backend: net.ParseIP("10.0.0.2"), Transition: health.Transition{ From: health.StateUnknown, To: health.StateUp, }, } c.mu.Lock() c.emit(e) c.mu.Unlock() select { case got := <-ch: if got.FrontendName != "web" { t.Errorf("event FrontendName: got %q, want web", got.FrontendName) } if got.BackendName != "be0" { t.Errorf("event BackendName: got %q, want be0", got.BackendName) } if got.Transition.To != health.StateUp { t.Errorf("event To state: got %s, want up", got.Transition.To) } case <-time.After(time.Second): t.Error("timed out waiting for event") } } func TestSetFrontendPoolBackendWeight(t *testing.T) { cfg := makeTestConfig(time.Hour, 3, 2) c := New(cfg) // Valid weight change. fe, err := c.SetFrontendPoolBackendWeight("web", "primary", "be0", 42) if err != nil { t.Fatalf("SetFrontendPoolBackendWeight: %v", err) } if fe.Pools[0].Backends["be0"].Weight != 42 { t.Errorf("weight: got %d, want 42", fe.Pools[0].Backends["be0"].Weight) } // Persisted in live config. got, _ := c.GetFrontend("web") if got.Pools[0].Backends["be0"].Weight != 42 { t.Errorf("config weight: got %d, want 42", got.Pools[0].Backends["be0"].Weight) } // Out-of-range weight. if _, err := c.SetFrontendPoolBackendWeight("web", "primary", "be0", 101); err == nil { t.Error("expected error for weight 101") } // Unknown frontend. if _, err := c.SetFrontendPoolBackendWeight("nope", "primary", "be0", 50); err == nil { t.Error("expected error for unknown frontend") } // Unknown pool. if _, err := c.SetFrontendPoolBackendWeight("web", "nope", "be0", 50); err == nil { t.Error("expected error for unknown pool") } // Unknown backend. if _, err := c.SetFrontendPoolBackendWeight("web", "primary", "nope", 50); err == nil { t.Error("expected error for unknown backend in pool") } } func TestEnableDisable(t *testing.T) { cfg := makeTestConfig(time.Hour, 3, 2) c := New(cfg) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go c.fanOut(ctx) // Seed a worker as EnableBackend/DisableBackend require one in c.workers. wCtx, wCancel := context.WithCancel(ctx) c.mu.Lock() c.runCtx = ctx c.workers["be0"] = &worker{ backend: health.New("be0", net.ParseIP("10.0.0.2"), 2, 3), hc: cfg.HealthChecks["icmp"], entry: cfg.Backends["be0"], cancel: wCancel, } c.mu.Unlock() _ = wCtx b, ok := c.DisableBackend("be0") if !ok { t.Fatal("DisableBackend: not found") } if b.Health.State != health.StateDisabled { t.Errorf("after disable: state=%s, want disabled", b.Health.State) } if b.Config.Enabled { t.Error("after disable: Enabled should be false") } // Backend should still be visible after disable. snap, ok := c.GetBackend("be0") if !ok { t.Fatal("GetBackend after disable: not found") } if snap.Config.Enabled { t.Error("GetBackend after disable: Enabled should be false") } b, ok = c.EnableBackend("be0") if !ok { t.Fatal("EnableBackend: not found") } if b.Health.State != health.StateUnknown { t.Errorf("after enable: state=%s, want unknown", b.Health.State) } if !b.Config.Enabled { t.Error("after enable: Enabled should be true") } } func TestPauseResume(t *testing.T) { cfg := makeTestConfig(time.Hour, 3, 2) c := New(cfg) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go c.fanOut(ctx) c.mu.Lock() c.runCtx = ctx _, wCancel := context.WithCancel(ctx) c.workers["be0"] = &worker{ backend: health.New("be0", net.ParseIP("10.0.0.2"), 2, 3), hc: cfg.HealthChecks["icmp"], entry: cfg.Backends["be0"], cancel: wCancel, wakeCh: make(chan struct{}, 1), } c.mu.Unlock() b, err := c.PauseBackend("be0") if err != nil { t.Fatalf("PauseBackend: %v", err) } if b.Health.State != health.StatePaused { t.Errorf("after pause: %s", b.Health.State) } b, err = c.ResumeBackend("be0") if err != nil { t.Fatalf("ResumeBackend: %v", err) } if b.Health.State != health.StateUnknown { t.Errorf("after resume: %s", b.Health.State) } // Pause/resume on a disabled backend must return an error. c.mu.Lock() c.workers["be0"].entry.Enabled = false c.mu.Unlock() if _, err := c.PauseBackend("be0"); err == nil { t.Error("PauseBackend on disabled backend: expected error") } if _, err := c.ResumeBackend("be0"); err == nil { t.Error("ResumeBackend on disabled backend: expected error") } }