package keepalived import ( "bytes" "strings" "testing" "git.netcell-it.de/projekte/edgeguard-native/internal/models" ) func render(t *testing.T, v View) string { t.Helper() var buf bytes.Buffer if err := tpl.Execute(&buf, v); err != nil { t.Fatalf("template execute: %v", err) } return buf.String() } func testView() View { return View{ State: "BACKUP", Interface: "eth0", RouterID: 51, Priority: 200, SrcIP: "89.163.205.6", PeerIP: "89.163.205.8", AuthPass: "edgeguard", VIPs: []VIPEntry{{Address: "89.163.205.100", Prefix: 24, Device: "eth0"}}, HBInterface: "ens19", HBSrcIP: "169.254.0.1", HBPeerIP: "169.254.0.2", HBRouterID: 52, GWCheckIP: "89.163.205.1", PreemptDelay: 120, } } // VIP-Affinität zum PG-Primary (Incident-2026-08-03-Fix): mit PreemptDelay // tragen BEIDE Instanzen `preempt_delay N` statt nopreempt, damit der // bevorzugte Node die VIP nach Erholung zurückholt — aber erst nach N Sekunden // Stabilität. Kein Node darf `state MASTER` starten (sonst kein sauberes // Election). Preempt ist nur sicher, WEIL keepalived-check.sh haproxy-Bereit- // schaft (aktiv + :443) mitprüft (siehe dortiger Kommentar). func TestTemplatePreemptDelayOnBothInstances(t *testing.T) { out := render(t, testView()) if c := strings.Count(out, "preempt_delay 120"); c != 2 { t.Fatalf("erwarte preempt_delay 120 in VI_1 UND VI_HB (2×), gefunden: %d\n%s", c, out) } if strings.Contains(out, "nopreempt") { t.Fatalf("bei PreemptDelay>0 darf KEIN nopreempt gerendert werden:\n%s", out) } if strings.Contains(out, "state MASTER") { t.Fatalf("kein Node darf state MASTER starten:\n%s", out) } if c := strings.Count(out, "state BACKUP"); c != 2 { t.Fatalf("erwarte state BACKUP in beiden Instanzen, gefunden: %d", c) } } // Ohne PreemptDelay (==0) fällt das Template auf nopreempt zurück (Node bleibt // Backup, keine VIP-Rückkehr) — der sichere Default, falls Preempt je aus soll. func TestTemplateFallsBackToNopreempt(t *testing.T) { v := testView() v.PreemptDelay = 0 out := render(t, v) if c := strings.Count(out, "nopreempt"); c != 2 { t.Fatalf("erwarte nopreempt in beiden Instanzen (2×) bei PreemptDelay=0, gefunden: %d\n%s", c, out) } if strings.Contains(out, "preempt_delay") { t.Fatalf("bei PreemptDelay=0 darf KEIN preempt_delay gerendert werden:\n%s", out) } } // GARP muss forciert + periodic aufgefrischt werden, sonst altert die // VIP-MAC am Upstream-Switch und die Failover-IP wird unerreichbar. func TestTemplateGARPRefresh(t *testing.T) { out := render(t, testView()) for _, want := range []string{"vrrp_garp_master_refresh", "vrrp_garp_master_repeat"} { if !strings.Contains(out, want) { t.Fatalf("global_defs sollte %q enthalten:\n%s", want, out) } } } // advert_int 2 (statt 1): Master-Down ~6s — reißt nicht bei kurzen // VM-/Heartbeat-Hiccups (Flapping-Schutz im virtualisierten Cluster). func TestTemplateAdvertInt(t *testing.T) { out := render(t, testView()) if strings.Contains(out, "advert_int 1\n") { t.Fatalf("advert_int sollte 2 sein (nicht 1):\n%s", out) } if c := strings.Count(out, "advert_int 2"); c != 2 { t.Fatalf("erwarte advert_int 2 in beiden Instanzen, gefunden: %d", c) } } // Track-Scripts dürfen KEIN weight haben: in einer vrrp_sync_group ignoriert // keepalived gewichtete Scripts → Health-Checks wären wirkungslos. Ohne weight // wirken sie als FAULT-Trigger. func TestTemplateTrackScriptsUnweighted(t *testing.T) { out := render(t, testView()) if strings.Contains(out, "weight") { t.Fatalf("Track-Scripts dürfen kein weight tragen (Sync-Group ignoriert sie sonst):\n%s", out) } } // gw-Check darf nicht zu zucken (fall 5, nicht fall 2) — ein kurzer Upstream- // Blip soll keinen Failover erzwingen. func TestTemplateGatewayCheckNotTwitchy(t *testing.T) { out := render(t, testView()) if !strings.Contains(out, "fall 5") { t.Fatalf("chk_gateway sollte fall 5 nutzen:\n%s", out) } } // buildView: State immer BACKUP, Priorität aus pg_role. func TestBuildViewStateAlwaysBackup(t *testing.T) { g := &generator{localID: "n1"} cs := &models.ClusterSettings{VRRPRouterID: 51} pub := "89.163.205.6" cases := []struct { pgRole, role string wantPrio int }{ {"primary", "primary", 200}, {"standby", "primary", 100}, {"", "primary", 200}, {"", "", 100}, } for _, c := range cases { local := &models.HANode{ID: "n1", PGRole: c.pgRole, Role: c.role, PublicIP: &pub} v := g.buildView(cs, nil, local, nil) if v.State != "BACKUP" { t.Errorf("pg_role=%q role=%q: State=%q, erwarte immer BACKUP", c.pgRole, c.role, v.State) } if v.Priority != c.wantPrio { t.Errorf("pg_role=%q role=%q: Priority=%d, erwarte %d", c.pgRole, c.role, v.Priority, c.wantPrio) } if v.PreemptDelay != 120 { t.Errorf("pg_role=%q role=%q: PreemptDelay=%d, erwarte 120", c.pgRole, c.role, v.PreemptDelay) } } }