Preempt-Rückkehr (preempt_delay 120) wieder aktiv: der bevorzugte Node (PG-Primary, Prio 200) holt die VIP nach Erholung zurück. Der Incident 2026-08-03 (halb-kaputter Node riss die VIP an sich) wird verhindert, weil keepalived-check.sh jetzt zusätzlich fordert: - haproxy-Prozess aktiv - :443 gebunden (bedient wirklich Traffic) Ein nicht-bedienender Node geht damit in FAULT und kann NICHT (mehr) preempten. Außerdem: CrowdSec-Management-Whitelist (Backend api_backend) fest ins postinst gebacken (Admin-SPA-Traffic wird nie mehr als http-crawl gebannt, IP-unabhängig, Incident-Root-Fix) + Altlast netcell-mgmt-whitelist.yaml wird aufgeräumt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
136 lines
4.8 KiB
Go
136 lines
4.8 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|